diff --git a/.env.example b/.env.example index 95b2d9c6..4576b427 100644 --- a/.env.example +++ b/.env.example @@ -95,6 +95,14 @@ OUTLINE_API_KEY= ERPNEXT_BASE_URL=https://erp.example.com/ ERPNEXT_API_TIMEOUT_SECONDS=20.0 ERPNEXT_API_KEY= +# Dedicated Frappe Webhook Secret for submitted Bank Transaction notifications. +# This is verified as X-Frappe-Webhook-Signature (base64 HMAC-SHA256); it does +# not fall back to WEBHOOK_SHARED_SECRET or API_SHARED_SECRET. +ERPNEXT_BANK_TRANSACTION_WEBHOOK_SIGNING_SECRET= +# Payment automation flags are intentionally omitted so the protected runtime +# configuration dashboard can manage them. Set any of the following here only +# to lock it to deployment configuration: PROJECT_PAYMENT_AUTOMATION_ENABLED, +# PROJECT_PAYMENT_NOTIFICATIONS_ENABLED, PROJECT_PAYMENT_RECOVERY_INTERVAL_SECONDS. # Optional: Discord logs webhook for operator-visible logs from commands/jobs DISCORD_LOGS_WEBHOOK_URL= # Optional: wait for Discord server confirmation before returning from webhook call diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index eef5563f..dbd33db5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -44,6 +44,38 @@ Job state is persisted in Postgres table `jobs` with: Redis is the delivery transport. Postgres remains the source of truth for retries, idempotency, and inspection. +Payment-routing automation uses an additional semantic event/action outbox in +Postgres. A signed ERPNext Bank Transaction webhook supplies only a document +identity; the worker fetches canonical ERP data, evaluates typed rules, records +project allocations, and asks the Discord bot (the sole Discord executor) to +deliver an outbox-backed notification to a registered private project channel. +ERPNext remains the accounting source of truth; this service intentionally does +not run a second Plaid transaction sync or store Plaid credentials. Automatic +allocation is a single database transaction that fences the rule version, ERP +revision, open-project state, allocation total, and notification outbox. Lower +confidence matches remain immutable human-review suggestions instead. +An approved suggestion with strong canonical payer/reference evidence can +deterministically add a low-priority learned *suggestion* rule. The rule carries +an opaque provenance fingerprint, is visible and disableable to operators, and +is fail-closed from automatic payment routing even if its stored mode were +tampered with. This gives the system a reviewable feedback loop without +turning a financial decision into opaque autonomous classification. +The worker refreshes the targeted ERP project immediately before any allocation +so an old local `Open` cache entry cannot route a recently closed project. +Immediately before a Discord side effect, it also re-reads the ERP project and +Bank Transaction, requiring the allocation's captured transaction revision; a +closed project, canceled transaction, or revised transaction blocks the delayed +outbox row. When a newer canonical revision is persisted, an existing automatic +configured-rule allocation for the old revision is retained for audit but marked +superseded, which also makes its unsent outbox row ineligible. Human-reviewed, +manual, and ERP-reconciled allocations are not changed automatically. A +correction after a Discord message has succeeded remains an explicit +reconciliation case: v1 does not retract Discord content. +The bot keeps its own durable receipt/lease because the Discord send and worker +acknowledgement occur in separate processes; a periodic recovery job reclaims +stale action, outbox, and bot leases and retries idempotent learned-suggestion +derivation from approved feedback. + Worker schema is managed by Alembic migrations in `apps/worker/src/five08/worker/migrations`. The Web/API service applies these migrations on startup through `run_job_migrations()`. diff --git a/apps/admin_dashboard/src/main.tsx b/apps/admin_dashboard/src/main.tsx index ccfe184a..5788e7e6 100644 --- a/apps/admin_dashboard/src/main.tsx +++ b/apps/admin_dashboard/src/main.tsx @@ -69,6 +69,12 @@ import { newsletterProviderResults, } from "@/views/newsletter-models" import { NewsletterView } from "@/views/newsletter-view" +import { + type PaymentAutomationRule, + type PaymentAutomationRuleDraft, + type PaymentAutomationSuggestion, + PaymentAutomationView, +} from "@/views/payment-automation-view" import "./index.css" type View = @@ -81,6 +87,7 @@ type View = | "agent" | "audit" | "configuration" + | "payments" type SortDirection = "asc" | "desc" type GigTab = "gigs" | "leads" @@ -481,6 +488,7 @@ const routes: Record = { agent: "/dashboard/agent", audit: "/dashboard/audit", configuration: "/dashboard/configuration", + payments: "/dashboard/payments", } const routePermissions: Record = { @@ -493,6 +501,7 @@ const routePermissions: Record = { agent: "audit:read", audit: "audit:read", configuration: "configuration:read", + payments: "configuration:read", } const peopleFilterDefinitions = { @@ -892,6 +901,9 @@ function App() { const [auditEvents, setAuditEvents] = useState([]) const [agentReport, setAgentReport] = useState(null) const [configurationItems, setConfigurationItems] = useState([]) + const [paymentProjects, setPaymentProjects] = useState([]) + const [paymentRules, setPaymentRules] = useState([]) + const [paymentSuggestions, setPaymentSuggestions] = useState([]) const [configurationFocus, setConfigurationFocus] = useState<{ category: string nonce: number @@ -914,6 +926,7 @@ function App() { agent: { key: "occurred_at", direction: "desc" }, audit: { key: "occurred_at", direction: "desc" }, configuration: { key: "category", direction: "asc" }, + payments: { key: "priority", direction: "desc" }, }) const [minutes, setMinutes] = useState("60") @@ -2035,6 +2048,112 @@ function App() { } } + async function loadPaymentAutomation() { + setBusy("paymentAutomation", true) + try { + const [rulesPayload, suggestionsPayload, projectsPayload] = await Promise.all([ + requestJson<{ rules: PaymentAutomationRule[] }>("/dashboard/api/project-payment-rules"), + requestJson<{ suggestions: PaymentAutomationSuggestion[] }>( + "/dashboard/api/project-payment-suggestions?limit=100", + ), + requestJson("/dashboard/api/projects?limit=100&status=Open"), + ]) + setPaymentRules(rulesPayload.rules || []) + setPaymentSuggestions(suggestionsPayload.suggestions || []) + setPaymentProjects(projectsPayload.projects || []) + } catch (error) { + showError(error, "Unable to load project payment automation") + } finally { + setBusy("paymentAutomation", false) + } + } + + async function createPaymentRule(draft: PaymentAutomationRuleDraft) { + setBusy("paymentRuleCreate", true) + try { + const payload = await requestJson<{ rule: PaymentAutomationRule }>( + "/dashboard/api/project-payment-rules", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(draft), + }, + ) + setPaymentRules((current) => [payload.rule, ...current]) + showToast("Created payment routing rule", "ok") + return true + } catch (error) { + showError(error, "Unable to create payment routing rule") + return false + } finally { + setBusy("paymentRuleCreate", false) + } + } + + async function disablePaymentRule(rule: PaymentAutomationRule) { + setBusy(`paymentRule:${rule.id}`, true) + try { + const payload = await requestJson<{ rule: PaymentAutomationRule }>( + `/dashboard/api/project-payment-rules/${encodeURIComponent(rule.id)}`, + { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ expected_version: rule.version }), + }, + ) + setPaymentRules((current) => + current.map((currentRule) => (currentRule.id === rule.id ? payload.rule : currentRule)), + ) + showToast("Disabled payment routing rule", "ok") + return true + } catch (error) { + showError(error, "Unable to disable payment routing rule") + return false + } finally { + setBusy(`paymentRule:${rule.id}`, false) + } + } + + async function reviewPaymentSuggestion( + suggestion: PaymentAutomationSuggestion, + decision: "approve" | "reject", + ) { + setBusy(`paymentSuggestion:${suggestion.id}`, true) + try { + const payload = await requestJson<{ + status?: string + learning?: { status?: string } + }>( + `/dashboard/api/project-payment-suggestions/${encodeURIComponent(suggestion.id)}/${decision}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }, + ) + setPaymentSuggestions((current) => + current.filter((currentSuggestion) => currentSuggestion.id !== suggestion.id), + ) + if (decision === "approve" && payload.learning?.status === "created") { + void loadPaymentAutomation() + } + showToast( + decision === "approve" + ? payload.status === "approved_pending_recovery" + ? "Approved payment suggestion; delivery recovery is queued" + : "Approved payment suggestion; routing is queued" + : "Rejected payment suggestion", + decision === "approve" && payload.status === "approved_pending_recovery" ? "warning" : "ok", + ) + return true + } catch (error) { + showError(error, `Unable to ${decision} payment suggestion`) + return false + } finally { + setBusy(`paymentSuggestion:${suggestion.id}`, false) + } + } + async function updateConfigurationValue(key: string, value: string) { setBusy(`configuration:${key}`, true) try { @@ -2412,6 +2531,7 @@ function App() { if (view === "jobs") void loadJobs() if (view === "agent") void loadAgentReport() if (view === "audit") void loadAuditEvents() + if (view === "payments") void loadPaymentAutomation() if (view === "configuration") { void loadConfiguration() void loadJobPostChannels({ includeAvailable: true }) @@ -2434,6 +2554,7 @@ function App() { if (view === "jobs") void loadJobs() if (view === "agent") void loadAgentReport() if (view === "audit") void loadAuditEvents() + if (view === "payments") void loadPaymentAutomation() if (view === "configuration") { void loadConfiguration() void loadJobPostChannels({ includeAvailable: true }) @@ -2698,6 +2819,7 @@ function App() { ["people", "People", Users], ["gigs", "Gigs", BriefcaseBusiness], ["projects", "Projects", FolderKanban], + ["payments", "Payments", Bell], ["onboarding", "Onboarding", ClipboardList], ["newsletter", "Newsletter", Mail], ["jobs", "Background tasks", Activity], @@ -2948,6 +3070,21 @@ function App() { ) : null} + {view === "payments" ? ( + reviewPaymentSuggestion(suggestion, "approve")} + onRejectSuggestion={(suggestion) => reviewPaymentSuggestion(suggestion, "reject")} + /> + ) : null} + {view === "configuration" ? ( > = {}, +) { + return render( + , + ) +} + +describe("PaymentAutomationView", () => { + it("builds a safely constrained automatic payment routing rule", async () => { + const onCreateRule = vi.fn().mockResolvedValue(true) + renderPaymentAutomationView({ onCreateRule }) + + fireEvent.change(screen.getByLabelText("Rule project"), { target: { value: "project-1" } }) + fireEvent.change(screen.getByLabelText("Rule mode"), { target: { value: "automatic" } }) + fireEvent.change(screen.getByLabelText("Rule identity value"), { + target: { value: "Acme Ltd" }, + }) + fireEvent.change(screen.getByLabelText("Rule currency"), { target: { value: "gbp" } }) + fireEvent.change(screen.getByLabelText("Rule exact amount"), { target: { value: "1500.00" } }) + fireEvent.click(screen.getByRole("button", { name: "Create rule" })) + + await waitFor(() => expect(onCreateRule).toHaveBeenCalledTimes(1)) + expect(onCreateRule).toHaveBeenCalledWith({ + project_id: "project-1", + priority: 0, + mode: "automatic", + enabled: true, + conditions: [ + { fact: "transaction.direction", operator: "equals", value: "inbound" }, + { fact: "transaction.counterparty", operator: "contains", value: "Acme Ltd" }, + { fact: "transaction.currency", operator: "equals", value: "GBP" }, + { fact: "transaction.amount", operator: "equals", value: "1500.00" }, + ], + actions: [ + { + action_type: "project_payment.route", + payload: { project_id: "project-1", amount: "1500.00" }, + }, + ], + }) + }) + + it("requires explicit confirmation before an allocation decision", () => { + const onApproveSuggestion = vi.fn().mockResolvedValue(true) + renderPaymentAutomationView({ onApproveSuggestion }) + + fireEvent.click(screen.getByRole("button", { name: "Approve allocation" })) + expect(onApproveSuggestion).not.toHaveBeenCalled() + expect( + screen.getByText("Approve this project allocation and queue the channel notification?"), + ).toBeVisible() + + fireEvent.click(screen.getByRole("button", { name: "Confirm approve" })) + expect(onApproveSuggestion).toHaveBeenCalledWith(suggestions[0]) + }) + + it("filters rules and suggestions to the selected project", () => { + renderPaymentAutomationView() + + fireEvent.change(screen.getByLabelText("Filter payment automation by project"), { + target: { value: "project-2" }, + }) + + expect(screen.getByText("No payment rules match this filter.")).toBeVisible() + expect(screen.getByText("No payment suggestions are waiting for review.")).toBeVisible() + }) + + it("labels feedback-derived rules so an operator can distinguish them", () => { + renderPaymentAutomationView({ + rules: [{ ...rules[0], origin: "learned", mode: "suggest" }], + }) + + expect(screen.getByText("learned")).toBeVisible() + expect(screen.queryByText("configured")).not.toBeInTheDocument() + }) +}) diff --git a/apps/admin_dashboard/src/views/payment-automation-view.tsx b/apps/admin_dashboard/src/views/payment-automation-view.tsx new file mode 100644 index 00000000..fca060e5 --- /dev/null +++ b/apps/admin_dashboard/src/views/payment-automation-view.tsx @@ -0,0 +1,565 @@ +import { Check, Lightbulb, Plus, RefreshCw, X } from "lucide-react" +import { type FormEvent, useMemo, useState } from "react" + +import { Empty } from "@/components/empty" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select } from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { cn } from "@/lib/utils" + +export type PaymentAutomationProject = { + id: string + display_name: string + source_status?: string +} + +export type PaymentAutomationCondition = { + fact: string + operator: string + value?: unknown +} + +export type PaymentAutomationAction = { + action_type: string + payload: Record +} + +export type PaymentAutomationRule = { + id: string + project_id?: string | null + origin?: "configured" | "learned" | string + priority: number + mode: "observe" | "suggest" | "automatic" | string + enabled: boolean + version: number + conditions: PaymentAutomationCondition[] + actions: PaymentAutomationAction[] + created_by?: string | null +} + +export type PaymentAutomationSuggestion = { + id: string + project_id?: string | null + payload: Record + mode: string + status: string + attempts: number + review_decision?: string | null + subject_id?: string + subject_snapshot?: Record +} + +export type PaymentAutomationRuleDraft = { + project_id: string + priority: number + mode: "suggest" | "automatic" + enabled: boolean + conditions: PaymentAutomationCondition[] + actions: PaymentAutomationAction[] +} + +type IdentifierFact = + | "transaction.counterparty" + | "transaction.description" + | "transaction.reference_number" + +const identifierOptions: { value: IdentifierFact; label: string }[] = [ + { value: "transaction.counterparty", label: "Counterparty" }, + { value: "transaction.description", label: "Description" }, + { value: "transaction.reference_number", label: "Reference number" }, +] + +function conditionLabel(condition: PaymentAutomationCondition) { + const label = condition.fact.replace("transaction.", "") + const operator = condition.operator.replaceAll("_", " ") + const value = + typeof condition.value === "string" + ? condition.value + : condition.value === undefined + ? "" + : JSON.stringify(condition.value) + return `${label} ${operator} ${value}`.trim() +} + +function snapshotText(snapshot: Record, key: string) { + const value = snapshot[key] + return typeof value === "string" && value.trim() ? value.trim() : "" +} + +function currencyAmount(snapshot: Record) { + const amount = snapshotText(snapshot, "amount") + const currency = snapshotText(snapshot, "currency") + return [amount, currency].filter(Boolean).join(" ") || "Amount unavailable" +} + +function PaymentAutomationView(props: { + projects: PaymentAutomationProject[] + rules: PaymentAutomationRule[] + suggestions: PaymentAutomationSuggestion[] + loading: Record + canWrite: boolean + onRefresh: () => void + onCreateRule: (draft: PaymentAutomationRuleDraft) => Promise + onDisableRule: (rule: PaymentAutomationRule) => Promise + onApproveSuggestion: (suggestion: PaymentAutomationSuggestion) => Promise + onRejectSuggestion: (suggestion: PaymentAutomationSuggestion) => Promise +}) { + const [selectedProjectId, setSelectedProjectId] = useState("") + const [identifierFact, setIdentifierFact] = useState("transaction.counterparty") + const [identifierValue, setIdentifierValue] = useState("") + const [currency, setCurrency] = useState("") + const [amount, setAmount] = useState("") + const [mode, setMode] = useState<"suggest" | "automatic">("suggest") + const [priority, setPriority] = useState("0") + const [formError, setFormError] = useState("") + const [pendingDecision, setPendingDecision] = useState<{ + suggestion: PaymentAutomationSuggestion + decision: "approve" | "reject" + } | null>(null) + + const projectById = useMemo( + () => new Map(props.projects.map((project) => [project.id, project])), + [props.projects], + ) + const visibleRules = selectedProjectId + ? props.rules.filter((rule) => rule.project_id === selectedProjectId) + : props.rules + const visibleSuggestions = selectedProjectId + ? props.suggestions.filter((suggestion) => suggestion.project_id === selectedProjectId) + : props.suggestions + const normalizedCurrency = currency.trim().toUpperCase() + const normalizedAmount = amount.trim() + const canSubmit = + props.canWrite && + Boolean(selectedProjectId && identifierValue.trim() && normalizedCurrency) && + (mode !== "automatic" || Boolean(normalizedAmount)) + + function projectName(projectId?: string | null) { + if (!projectId) return "Unknown project" + return projectById.get(projectId)?.display_name || projectId + } + + async function submitRule(event: FormEvent) { + event.preventDefault() + if (!canSubmit) { + setFormError( + mode === "automatic" + ? "Automatic rules require a project, identity match, exact currency, and exact amount." + : "Choose a project and provide an identity match and currency.", + ) + return + } + const parsedPriority = Number(priority) + if (!Number.isInteger(parsedPriority) || parsedPriority < -10_000 || parsedPriority > 10_000) { + setFormError("Priority must be a whole number between -10,000 and 10,000.") + return + } + const conditions: PaymentAutomationCondition[] = [ + { fact: "transaction.direction", operator: "equals", value: "inbound" }, + { fact: identifierFact, operator: "contains", value: identifierValue.trim() }, + { fact: "transaction.currency", operator: "equals", value: normalizedCurrency }, + ] + const payload: Record = { project_id: selectedProjectId } + if (normalizedAmount) { + conditions.push({ fact: "transaction.amount", operator: "equals", value: normalizedAmount }) + payload.amount = normalizedAmount + } + const created = await props.onCreateRule({ + project_id: selectedProjectId, + priority: parsedPriority, + mode, + enabled: true, + conditions, + actions: [{ action_type: "project_payment.route", payload }], + }) + if (!created) return + setFormError("") + setIdentifierValue("") + setCurrency("") + setAmount("") + setMode("suggest") + setPriority("0") + } + + async function confirmSuggestionDecision() { + if (!pendingDecision) return + const { suggestion, decision } = pendingDecision + const completed = + decision === "approve" + ? await props.onApproveSuggestion(suggestion) + : await props.onRejectSuggestion(suggestion) + if (completed) setPendingDecision(null) + } + + return ( +
+ + +
+ Project payment automation +

+ Categorize canonical ERP bank receipts, review uncertain matches, and notify only + registered private project channels. +

+
+ +
+ +
+ An approval can create a low-priority learned suggestion when its canonical ERP evidence + has a payer identity and currency. Learned rules always require review; only configured + automatic rules with exact identity, currency, and amount may route without review. +
+ +
+
+ + + +
+ Create routing rule +

+ Start with a suggestion. Automatic routing is intentionally stricter. +

+
+ + {mode === "automatic" ? "Automatic needs exact proof" : "Human review first"} + +
+ +
void submitRule(event)}> +
+ + + + + + + +
+ {formError ?

{formError}

: null} + {!props.canWrite ? ( +

+ You can inspect rules and suggestions, but configuration write permission is + required to change routing. +

+ ) : null} +
+ +
+
+
+
+ + + +
+ Configured rules +

+ Disabling a rule retains its event and decision history. +

+
+ {visibleRules.length} shown +
+ +
+ + + + Project + Mode + Match conditions + Version + Created by + Action + + + + {visibleRules.map((rule) => ( + + +
{projectName(rule.project_id)}
+
{rule.id}
+
+ +
+ + {rule.mode} + + + {rule.enabled ? "enabled" : "disabled"} + + + {rule.origin === "learned" ? "learned" : "configured"} + +
+
+ +
+ {rule.conditions.map((condition) => ( + + {conditionLabel(condition)} + + ))} +
+
+ v{rule.version} + + {rule.created_by || "Unknown"} + + + {rule.enabled && props.canWrite ? ( + + ) : null} + +
+ ))} +
+
+
+
+ + + +
+ Payment suggestions +

+ Review the canonical ERP receipt before authorizing a project allocation. +

+
+ {visibleSuggestions.length} awaiting review +
+ + + {visibleSuggestions.map((suggestion) => { + const snapshot = suggestion.subject_snapshot || {} + const counterparty = snapshotText(snapshot, "counterparty") + const description = snapshotText(snapshot, "description") + const reference = snapshotText(snapshot, "reference_number") + return ( +
+
+
+

{projectName(suggestion.project_id)}

+

+ {currencyAmount(snapshot)} ·{" "} + {counterparty || description || "Unidentified payer"} +

+
+
+ {suggestion.mode} + {suggestion.status.replaceAll("_", " ")} +
+
+
+
+
Description
+
{description || "—"}
+
+
+
Reference
+
{reference || "—"}
+
+
+
ERP transaction
+
{suggestion.subject_id || "—"}
+
+
+ {props.canWrite ? ( + pendingDecision?.suggestion.id === suggestion.id ? ( +
+

+ {pendingDecision.decision === "approve" + ? "Approve this project allocation and queue the channel notification?" + : "Reject this project allocation suggestion?"} +

+
+ + +
+
+ ) : ( +
+ + +
+ ) + ) : null} +
+ ) + })} +
+ + Approval and rejection decisions are retained with the source evidence. An eligible + approval may teach a future suggestion, but it never silently turns into automatic + payment routing. +
+
+
+
+ ) +} + +export { PaymentAutomationView } diff --git a/apps/api/src/five08/backend/api.py b/apps/api/src/five08/backend/api.py index 71f7b9bc..34192cd3 100644 --- a/apps/api/src/five08/backend/api.py +++ b/apps/api/src/five08/backend/api.py @@ -5,6 +5,7 @@ import asyncio import base64 import contextlib +from decimal import Decimal, InvalidOperation import hashlib import hmac import logging @@ -41,6 +42,21 @@ AuditSource, insert_audit_event, ) +from five08.automation import AutomationRule +from five08.automation_store import ( + StoredAutomationAction, + StoredAutomationReviewAction, + StoredAutomationRule, + approve_automation_action, + create_automation_rule, + create_automation_rule_if_absent, + disable_automation_rule, + get_automation_review_action, + list_automation_actions_awaiting_review, + list_automation_rules, + reject_automation_action, + update_automation_rule, +) from five08.agent import ( AgentIdentityContext, AgentModelConfig, @@ -79,6 +95,7 @@ get_postgres_connection, get_redis_connection, is_postgres_healthy, + revive_dead_job, trusted_sql, ) from five08.backend.auth import ( @@ -141,6 +158,10 @@ DashboardProjectCreateRequest, DashboardProjectHistoricalMemberRemoveRequest, DashboardProjectHistoricalMemberRequest, + DashboardProjectPaymentRuleCreateRequest, + DashboardProjectPaymentRuleDisableRequest, + DashboardProjectPaymentRuleUpdateRequest, + DashboardProjectPaymentSuggestionReviewRequest, DashboardProjectStatusRequest, DashboardProjectUserRemoveRequest, DashboardProjectUserRequest, @@ -207,6 +228,15 @@ wiki_project_match_preview, wiki_row_by_key, ) +from five08.project_payments import ( + BANK_TRANSACTION_POSTED_EVENT, + PROJECT_PAYMENT_ALLOWED_FACT_PATHS, + PROJECT_PAYMENT_ROUTE_ACTION, + automatic_project_payment_rule_is_safe, + learned_project_payment_suggestion_rule, + project_is_open, + project_payment_rule_has_valid_scope, +) from five08.runtime_config import ( delete_runtime_config_value, list_runtime_config, @@ -225,6 +255,7 @@ from five08.worker.models import ( AuditEventPayload, DocusealWebhookPayload, + ERPNextBankTransactionWebhookPayload, EspoCRMWebhookPayload, GoogleFormsIntakePayload, TallyWebhookField, @@ -294,6 +325,9 @@ def __init__(self, error: str, *, status_code: int = 400) -> None: sync_person_from_crm_job = JOB_FUNCTIONS["sync_person_from_crm_job"] sync_projects_from_erpnext_job = JOB_FUNCTIONS["sync_projects_from_erpnext_job"] process_docuseal_agreement_job = JOB_FUNCTIONS["process_docuseal_agreement_job"] +ingest_erpnext_bank_transaction_job = JOB_FUNCTIONS[ + "ingest_erpnext_bank_transaction_job" +] TALLY_INTAKE_FIELD_LABEL_MAP = { "full name": "name", @@ -482,6 +516,13 @@ def _newsletter_sync_idempotency_key(*, now: datetime) -> str: return f"newsletter-sync:508-members:{bucket}" +def _project_payment_recovery_idempotency_key(*, now: datetime) -> str: + """Bucket payment recovery sweeps so only one is durable per interval.""" + interval_seconds = max(60, settings.project_payment_recovery_interval_seconds) + bucket = int(now.timestamp()) // interval_seconds + return f"project-payment-recovery:{bucket}" + + def _normalize_google_forms_input(value: str | None) -> str | None: if not isinstance(value, str): return None @@ -662,6 +703,43 @@ def _is_tally_webhook_authorized(request: Request, body: bytes) -> bool: return False +def _erpnext_webhook_signature_valid( + *, body: bytes, signature: str, secret: str +) -> bool: + """Validate Frappe's base64 HMAC-SHA256 webhook signature.""" + expected = base64.b64encode( + hmac.new(secret.encode("utf-8"), body, hashlib.sha256).digest() + ).decode("ascii") + return hmac.compare_digest(signature.strip(), expected) + + +def _is_erpnext_bank_transaction_webhook_authorized( + request: Request, + body: bytes, +) -> bool: + """Fail closed unless this route has its own Frappe webhook secret.""" + signing_secret = str( + settings.erpnext_bank_transaction_webhook_signing_secret or "" + ).strip() + if not signing_secret: + logger.error( + "Rejecting ERPNext Bank Transaction webhook: signing secret is not configured" + ) + return False + signature = request.headers.get("X-Frappe-Webhook-Signature", "") + if not signature: + logger.warning("Rejecting ERPNext Bank Transaction webhook: missing signature") + return False + if _erpnext_webhook_signature_valid( + body=body, + signature=signature, + secret=signing_secret, + ): + return True + logger.warning("Rejecting ERPNext Bank Transaction webhook: invalid signature") + return False + + def _tally_intake_dry_run_mode( request: Request, ) -> Literal["none", "webhook", "worker"]: @@ -822,6 +900,38 @@ async def _enqueue_newsletter_sync_job( return job +async def _enqueue_project_payment_recovery_job( + queue: QueueClient, + *, + reason: str, +) -> EnqueuedJob: + """Queue a durable sweep for actions/outbox rows stranded by a prior run. + + Unlike a normal duplicate, an existing recovery job is explicitly + redelivered. This closes the same Postgres-commit/Redis-delivery window as + the Bank Transaction webhook and makes an enabled feature recover work + created while it was disabled. + """ + now = datetime.now(tz=timezone.utc) + job: EnqueuedJob = await asyncio.to_thread( + enqueue_job, + queue=queue, + fn=JOB_FUNCTIONS["recover_project_payment_automation_job"], + args=(), + settings=settings, + idempotency_key=_project_payment_recovery_idempotency_key(now=now), + ) + if not job.created: + await asyncio.to_thread(queue.enqueue, job.id) + logger.info( + "Enqueued project payment recovery job id=%s created=%s reason=%s", + job.id, + job.created, + reason, + ) + return job + + async def _crm_sync_scheduler(app: FastAPI) -> None: queue = app.state.queue interval_seconds = max(1, settings.crm_sync_interval_seconds) @@ -856,6 +966,18 @@ async def _newsletter_sync_scheduler(app: FastAPI) -> None: await asyncio.sleep(interval_seconds) +async def _project_payment_recovery_scheduler(app: FastAPI) -> None: + """Periodically recover payment actions and notification outbox rows.""" + queue = app.state.queue + interval_seconds = max(60, settings.project_payment_recovery_interval_seconds) + while True: + try: + await _enqueue_project_payment_recovery_job(queue, reason="scheduler") + except Exception: + logger.exception("Failed scheduling project payment recovery job") + await asyncio.sleep(interval_seconds) + + async def _email_resume_scheduler() -> None: """Run periodic mailbox polling for resume ingestion.""" poller = ResumeMailboxProcessor(settings) @@ -5893,6 +6015,50 @@ async def _audit_dashboard_configuration_change( ) +async def _audit_dashboard_project_payment_rule_change( + session: AuthSession, + *, + result: AuditResult, + rule_id: str | None, + action: str, + metadata: dict[str, Any] | None = None, +) -> None: + """Write a compact audit trail without storing payment-match text twice.""" + actor_provider, actor_subject = _session_audit_actor(session) + await _write_auth_audit_event( + action=action, + result=result, + actor_subject=actor_subject, + actor_display_name=session.display_name, + actor_provider=actor_provider, + resource_type="project_payment_rule", + resource_id=rule_id, + metadata={"source": "dashboard", **(metadata or {})}, + ) + + +async def _audit_dashboard_project_payment_suggestion_review( + session: AuthSession, + *, + result: AuditResult, + action_id: str | None, + action: str, + metadata: dict[str, Any] | None = None, +) -> None: + """Audit a human decision without duplicating payment evidence in the log.""" + actor_provider, actor_subject = _session_audit_actor(session) + await _write_auth_audit_event( + action=action, + result=result, + actor_subject=actor_subject, + actor_display_name=session.display_name, + actor_provider=actor_provider, + resource_type="project_payment_suggestion", + resource_id=action_id, + metadata={"source": "dashboard", **(metadata or {})}, + ) + + async def _audit_dashboard_job_channel_change( session: AuthSession, *, @@ -7678,6 +7844,721 @@ async def dashboard_configuration_handler(request: Request) -> JSONResponse: return JSONResponse({"items": items}) +def _dashboard_project_payment_rule_payload( + stored_rule: StoredAutomationRule, +) -> dict[str, Any]: + """Render a typed rule without exposing unrelated event/evaluation records.""" + payload = stored_rule.rule.model_dump(mode="json") + payload["created_by"] = stored_rule.created_by + return payload + + +def _dashboard_project_payment_action_payload( + stored_action: StoredAutomationAction, +) -> dict[str, Any]: + """Render the decision state without leaking internal lease/idempotency data.""" + return { + "id": stored_action.id, + "event_id": stored_action.event_id, + "action_type": stored_action.action_type, + "payload": stored_action.payload, + "mode": stored_action.mode, + "disposition": stored_action.disposition, + "status": stored_action.status.value, + "attempts": stored_action.attempts, + "project_id": stored_action.rule_project_id, + "approved_by": stored_action.approved_by, + "review_decision": ( + stored_action.review_decision.value + if stored_action.review_decision is not None + else None + ), + "reviewed_by": stored_action.reviewed_by, + "reviewed_at": ( + stored_action.reviewed_at.isoformat() + if stored_action.reviewed_at is not None + else None + ), + } + + +def _dashboard_project_payment_suggestion_payload( + review: StoredAutomationReviewAction, +) -> dict[str, Any]: + """Render a reviewable proposal with its immutable canonical ERP evidence.""" + payload = _dashboard_project_payment_action_payload(review.action) + payload["subject_id"] = review.subject_id + payload["subject_snapshot"] = review.subject_snapshot + return payload + + +def _project_payment_rule_from_dashboard_payload( + payload: DashboardProjectPaymentRuleCreateRequest, + *, + rule_id: str, + version: int, +) -> AutomationRule: + """Build and validate the one supported v1 payment-routing rule shape.""" + project_id = _valid_uuid_or_none(payload.project_id) + if project_id is None: + raise ValueError("invalid_project_id") + rule = AutomationRule( + id=rule_id, + project_id=project_id, + event_type=BANK_TRANSACTION_POSTED_EVENT, + priority=payload.priority, + mode=payload.mode, + enabled=payload.enabled, + version=version, + conditions=payload.conditions, + actions=payload.actions, + ) + if len(rule.actions) != 1 or ( + rule.actions[0].action_type != PROJECT_PAYMENT_ROUTE_ACTION + ): + raise ValueError("unsupported_project_payment_action") + unknown_payload_keys = set(rule.actions[0].payload) - {"project_id", "amount"} + if unknown_payload_keys: + raise ValueError("unsupported_project_payment_action_payload") + amount = rule.actions[0].payload.get("amount") + if amount is not None: + try: + parsed_amount = Decimal(str(amount).strip()) + except (InvalidOperation, ValueError, AttributeError) as exc: + raise ValueError("invalid_project_payment_amount") from exc + if not parsed_amount.is_finite() or parsed_amount <= 0: + raise ValueError("invalid_project_payment_amount") + unknown_fact_paths = { + condition.fact for condition in rule.conditions + } - PROJECT_PAYMENT_ALLOWED_FACT_PATHS + if unknown_fact_paths: + raise ValueError("unsupported_project_payment_fact") + if not project_payment_rule_has_valid_scope(rule): + raise ValueError("project_payment_rule_scope_mismatch") + if rule.mode.value == "automatic" and not automatic_project_payment_rule_is_safe( + rule + ): + raise ValueError("unsafe_automatic_project_payment_rule") + return rule + + +async def _dashboard_project_payment_rule_or_error( + payload: DashboardProjectPaymentRuleCreateRequest, + *, + rule_id: str, + version: int, +) -> tuple[AutomationRule | None, JSONResponse | None]: + """Validate rule policy and ensure its target is an active local project.""" + try: + rule = _project_payment_rule_from_dashboard_payload( + payload, + rule_id=rule_id, + version=version, + ) + except ValueError as exc: + return None, JSONResponse({"error": str(exc)}, status_code=400) + assert rule.project_id is not None + is_open = await asyncio.to_thread( + project_is_open, settings, project_id=rule.project_id + ) + if not is_open: + return None, JSONResponse({"error": "project_not_open"}, status_code=409) + return rule, None + + +async def dashboard_project_payment_rules_handler( + request: Request, + project_id: str | None = Query(default=None), +) -> JSONResponse: + """List the dashboard-manageable, typed Bank Transaction rules.""" + _, error_response = await _dashboard_session_or_error( + request, + required_permission=DASHBOARD_PERMISSION_CONFIGURATION_READ, + ) + if error_response is not None: + return error_response + normalized_project_id: str | None = None + if project_id is not None: + normalized_project_id = _valid_uuid_or_none(project_id) + if normalized_project_id is None: + return JSONResponse({"error": "invalid_project_id"}, status_code=400) + rules = await asyncio.to_thread( + list_automation_rules, + settings, + event_type=BANK_TRANSACTION_POSTED_EVENT, + project_id=normalized_project_id, + ) + return JSONResponse( + {"rules": [_dashboard_project_payment_rule_payload(rule) for rule in rules]} + ) + + +async def dashboard_create_project_payment_rule_handler( + request: Request, +) -> JSONResponse: + """Create one auditable, declarative payment-routing rule.""" + session, error_response = await _dashboard_session_or_error( + request, + required_permission=DASHBOARD_PERMISSION_CONFIGURATION_WRITE, + ) + if error_response is not None: + return error_response + assert session is not None + csrf_error = _dashboard_same_origin_post_or_error(request) + if csrf_error is not None: + return csrf_error + try: + payload = DashboardProjectPaymentRuleCreateRequest.model_validate( + await request.json() + ) + except (ValidationError, TypeError, ValueError) as exc: + await _audit_dashboard_project_payment_rule_change( + session, + result=AuditResult.ERROR, + rule_id=None, + action="project_payment_rule.create", + metadata={"error": "invalid_project_payment_rule_payload"}, + ) + return JSONResponse( + {"error": "invalid_project_payment_rule_payload", "detail": str(exc)}, + status_code=400, + ) + rule_id = str(uuid4()) + rule, rule_error = await _dashboard_project_payment_rule_or_error( + payload, + rule_id=rule_id, + version=1, + ) + if rule_error is not None: + await _audit_dashboard_project_payment_rule_change( + session, + result=AuditResult.ERROR, + rule_id=rule_id, + action="project_payment_rule.create", + metadata={"error": "project_payment_rule_validation_failed"}, + ) + return rule_error + assert rule is not None + actor_provider, actor_subject = _session_audit_actor(session) + try: + stored_rule = await asyncio.to_thread( + create_automation_rule, + settings, + rule=rule, + created_by=f"{actor_provider.value}:{actor_subject}", + ) + except Exception: + logger.exception("Failed creating project payment rule id=%s", rule.id) + await _audit_dashboard_project_payment_rule_change( + session, + result=AuditResult.ERROR, + rule_id=rule.id, + action="project_payment_rule.create", + metadata={"error": "project_payment_rule_create_failed"}, + ) + return JSONResponse( + {"error": "project_payment_rule_create_failed"}, status_code=500 + ) + await _audit_dashboard_project_payment_rule_change( + session, + result=AuditResult.SUCCESS, + rule_id=stored_rule.rule.id, + action="project_payment_rule.create", + metadata={ + "project_id": stored_rule.rule.project_id, + "mode": stored_rule.rule.mode.value, + "enabled": stored_rule.rule.enabled, + "priority": stored_rule.rule.priority, + }, + ) + return JSONResponse( + {"rule": _dashboard_project_payment_rule_payload(stored_rule)}, + status_code=201, + ) + + +async def dashboard_update_project_payment_rule_handler( + request: Request, + rule_id: str, +) -> JSONResponse: + """Version-check and replace one payment-routing rule definition.""" + session, error_response = await _dashboard_session_or_error( + request, + required_permission=DASHBOARD_PERMISSION_CONFIGURATION_WRITE, + ) + if error_response is not None: + return error_response + assert session is not None + csrf_error = _dashboard_same_origin_post_or_error(request) + if csrf_error is not None: + return csrf_error + normalized_rule_id = _valid_uuid_or_none(rule_id) + if normalized_rule_id is None: + return JSONResponse({"error": "invalid_rule_id"}, status_code=400) + try: + payload = DashboardProjectPaymentRuleUpdateRequest.model_validate( + await request.json() + ) + except (ValidationError, TypeError, ValueError) as exc: + await _audit_dashboard_project_payment_rule_change( + session, + result=AuditResult.ERROR, + rule_id=normalized_rule_id, + action="project_payment_rule.update", + metadata={"error": "invalid_project_payment_rule_payload"}, + ) + return JSONResponse( + {"error": "invalid_project_payment_rule_payload", "detail": str(exc)}, + status_code=400, + ) + rule, rule_error = await _dashboard_project_payment_rule_or_error( + payload, + rule_id=normalized_rule_id, + version=payload.expected_version, + ) + if rule_error is not None: + await _audit_dashboard_project_payment_rule_change( + session, + result=AuditResult.ERROR, + rule_id=normalized_rule_id, + action="project_payment_rule.update", + metadata={"error": "project_payment_rule_validation_failed"}, + ) + return rule_error + assert rule is not None + try: + stored_rule = await asyncio.to_thread( + update_automation_rule, + settings, + rule=rule, + expected_version=payload.expected_version, + ) + except Exception: + logger.exception( + "Failed updating project payment rule id=%s", normalized_rule_id + ) + await _audit_dashboard_project_payment_rule_change( + session, + result=AuditResult.ERROR, + rule_id=normalized_rule_id, + action="project_payment_rule.update", + metadata={"error": "project_payment_rule_update_failed"}, + ) + return JSONResponse( + {"error": "project_payment_rule_update_failed"}, status_code=500 + ) + if stored_rule is None: + return JSONResponse({"error": "stale_or_missing_rule"}, status_code=409) + await _audit_dashboard_project_payment_rule_change( + session, + result=AuditResult.SUCCESS, + rule_id=stored_rule.rule.id, + action="project_payment_rule.update", + metadata={ + "project_id": stored_rule.rule.project_id, + "mode": stored_rule.rule.mode.value, + "enabled": stored_rule.rule.enabled, + "priority": stored_rule.rule.priority, + "version": stored_rule.rule.version, + }, + ) + return JSONResponse({"rule": _dashboard_project_payment_rule_payload(stored_rule)}) + + +async def dashboard_disable_project_payment_rule_handler( + request: Request, + rule_id: str, +) -> JSONResponse: + """Soft-disable one rule while retaining its historical evaluation evidence.""" + session, error_response = await _dashboard_session_or_error( + request, + required_permission=DASHBOARD_PERMISSION_CONFIGURATION_WRITE, + ) + if error_response is not None: + return error_response + assert session is not None + csrf_error = _dashboard_same_origin_post_or_error(request) + if csrf_error is not None: + return csrf_error + normalized_rule_id = _valid_uuid_or_none(rule_id) + if normalized_rule_id is None: + return JSONResponse({"error": "invalid_rule_id"}, status_code=400) + try: + payload = DashboardProjectPaymentRuleDisableRequest.model_validate( + await request.json() + ) + except (ValidationError, TypeError, ValueError) as exc: + return JSONResponse( + {"error": "invalid_project_payment_rule_payload", "detail": str(exc)}, + status_code=400, + ) + try: + stored_rule = await asyncio.to_thread( + disable_automation_rule, + settings, + rule_id=normalized_rule_id, + expected_version=payload.expected_version, + ) + except Exception: + logger.exception( + "Failed disabling project payment rule id=%s", normalized_rule_id + ) + await _audit_dashboard_project_payment_rule_change( + session, + result=AuditResult.ERROR, + rule_id=normalized_rule_id, + action="project_payment_rule.disable", + metadata={"error": "project_payment_rule_disable_failed"}, + ) + return JSONResponse( + {"error": "project_payment_rule_disable_failed"}, status_code=500 + ) + if stored_rule is None: + return JSONResponse({"error": "stale_or_missing_rule"}, status_code=409) + await _audit_dashboard_project_payment_rule_change( + session, + result=AuditResult.SUCCESS, + rule_id=stored_rule.rule.id, + action="project_payment_rule.disable", + metadata={ + "project_id": stored_rule.rule.project_id, + "version": stored_rule.rule.version, + }, + ) + return JSONResponse({"rule": _dashboard_project_payment_rule_payload(stored_rule)}) + + +async def dashboard_project_payment_suggestions_handler( + request: Request, + project_id: str | None = Query(default=None), + limit: int = Query(default=100, ge=1, le=1000), +) -> JSONResponse: + """List reviewable payment-route suggestions and their immutable evidence.""" + _, error_response = await _dashboard_session_or_error( + request, + required_permission=DASHBOARD_PERMISSION_CONFIGURATION_READ, + ) + if error_response is not None: + return error_response + normalized_project_id: str | None = None + if project_id is not None: + normalized_project_id = _valid_uuid_or_none(project_id) + if normalized_project_id is None: + return JSONResponse({"error": "invalid_project_id"}, status_code=400) + suggestions = await asyncio.to_thread( + list_automation_actions_awaiting_review, + settings, + event_type=BANK_TRANSACTION_POSTED_EVENT, + project_id=normalized_project_id, + limit=limit, + ) + return JSONResponse( + { + "suggestions": [ + _dashboard_project_payment_suggestion_payload(suggestion) + for suggestion in suggestions + ] + } + ) + + +async def _dashboard_project_payment_suggestion_review_payload_or_error( + request: Request, +) -> JSONResponse | None: + """Accept an empty decision acknowledgement and reject arbitrary client data.""" + body = await request.body() + if not body.strip(): + payload_data: object = {} + else: + try: + payload_data = json.loads(body) + except (TypeError, ValueError): + return JSONResponse( + {"error": "invalid_project_payment_suggestion_review_payload"}, + status_code=400, + ) + try: + DashboardProjectPaymentSuggestionReviewRequest.model_validate(payload_data) + except (ValidationError, TypeError, ValueError): + return JSONResponse( + {"error": "invalid_project_payment_suggestion_review_payload"}, + status_code=400, + ) + return None + + +async def _learn_project_payment_suggestion_from_approval( + stored_action: StoredAutomationAction, +) -> dict[str, str]: + """Compile eligible approved evidence into an idempotent review-only rule.""" + if stored_action.action_type != PROJECT_PAYMENT_ROUTE_ACTION: + return {"status": "not_eligible", "reason": "unsupported_action"} + if stored_action.rule_project_id is None: + return {"status": "not_eligible", "reason": "missing_project"} + if not await asyncio.to_thread( + project_is_open, + settings, + project_id=stored_action.rule_project_id, + ): + return {"status": "not_eligible", "reason": "project_not_open"} + review = await asyncio.to_thread( + get_automation_review_action, + settings, + action_id=stored_action.id, + ) + if review is None: + return {"status": "not_eligible", "reason": "missing_evidence"} + rule = learned_project_payment_suggestion_rule( + project_id=stored_action.rule_project_id, + subject_snapshot=review.subject_snapshot, + ) + if rule is None: + return {"status": "not_eligible", "reason": "insufficient_evidence"} + stored_rule, created = await asyncio.to_thread( + create_automation_rule_if_absent, + settings, + rule=rule, + created_by=f"system:project-payment-learning:{stored_action.id}", + ) + return { + "status": "created" if created else "existing", + "rule_id": stored_rule.rule.id, + } + + +async def dashboard_approve_project_payment_suggestion_handler( + request: Request, + action_id: str, +) -> JSONResponse: + """Approve one suggestion and enqueue its already-persisted action exactly once.""" + session, error_response = await _dashboard_session_or_error( + request, + required_permission=DASHBOARD_PERMISSION_CONFIGURATION_WRITE, + ) + if error_response is not None: + return error_response + assert session is not None + csrf_error = _dashboard_same_origin_post_or_error(request) + if csrf_error is not None: + return csrf_error + normalized_action_id = _valid_uuid_or_none(action_id) + if normalized_action_id is None: + await _audit_dashboard_project_payment_suggestion_review( + session, + result=AuditResult.ERROR, + action_id=None, + action="project_payment_suggestion.approve", + metadata={"error": "invalid_action_id"}, + ) + return JSONResponse({"error": "invalid_action_id"}, status_code=400) + payload_error = await _dashboard_project_payment_suggestion_review_payload_or_error( + request + ) + if payload_error is not None: + await _audit_dashboard_project_payment_suggestion_review( + session, + result=AuditResult.ERROR, + action_id=normalized_action_id, + action="project_payment_suggestion.approve", + metadata={"error": "invalid_project_payment_suggestion_review_payload"}, + ) + return payload_error + actor_provider, actor_subject = _session_audit_actor(session) + try: + stored_action = await asyncio.to_thread( + approve_automation_action, + settings, + action_id=normalized_action_id, + approved_by=f"{actor_provider.value}:{actor_subject}", + ) + except Exception: + logger.exception( + "Failed approving project payment suggestion action_id=%s", + normalized_action_id, + ) + await _audit_dashboard_project_payment_suggestion_review( + session, + result=AuditResult.ERROR, + action_id=normalized_action_id, + action="project_payment_suggestion.approve", + metadata={"error": "project_payment_suggestion_approval_failed"}, + ) + return JSONResponse( + {"error": "project_payment_suggestion_approval_failed"}, + status_code=500, + ) + if stored_action is None: + await _audit_dashboard_project_payment_suggestion_review( + session, + result=AuditResult.ERROR, + action_id=normalized_action_id, + action="project_payment_suggestion.approve", + metadata={"error": "suggestion_not_awaiting_review"}, + ) + return JSONResponse( + {"error": "suggestion_not_awaiting_review"}, status_code=409 + ) + + try: + learning = await _learn_project_payment_suggestion_from_approval(stored_action) + except Exception: + # The approval and its immutable evidence are already durable. Training + # must never make an approved payment allocation fail or invite a + # reviewer to submit it twice. + logger.exception( + "Approved payment suggestion but could not derive learning rule action_id=%s", + stored_action.id, + ) + learning = {"status": "deferred"} + + try: + job: EnqueuedJob = await asyncio.to_thread( + enqueue_job, + queue=request.app.state.queue, + fn=JOB_FUNCTIONS["execute_project_payment_automation_action_job"], + args=(stored_action.id,), + settings=settings, + idempotency_key=f"project-payment-action:{stored_action.id}", + ) + if not job.created: + await asyncio.to_thread(request.app.state.queue.enqueue, job.id) + except Exception: + # The approval is already durable. The periodic recovery job will claim + # this approved action, so do not encourage a second review attempt. + logger.exception( + "Approved project payment suggestion but could not enqueue action_id=%s", + stored_action.id, + ) + await _audit_dashboard_project_payment_suggestion_review( + session, + result=AuditResult.SUCCESS, + action_id=stored_action.id, + action="project_payment_suggestion.approve", + metadata={ + "project_id": stored_action.rule_project_id, + "queue_status": "recovery_required", + "learning": learning, + }, + ) + return JSONResponse( + { + "status": "approved_pending_recovery", + "action": _dashboard_project_payment_action_payload(stored_action), + "learning": learning, + }, + status_code=202, + ) + + await _audit_dashboard_project_payment_suggestion_review( + session, + result=AuditResult.SUCCESS, + action_id=stored_action.id, + action="project_payment_suggestion.approve", + metadata={ + "project_id": stored_action.rule_project_id, + "job_id": job.id, + "job_created": job.created, + "learning": learning, + }, + ) + return JSONResponse( + { + "status": "approved", + "action": _dashboard_project_payment_action_payload(stored_action), + "job": {"id": job.id, "created": job.created}, + "learning": learning, + }, + status_code=202, + ) + + +async def dashboard_reject_project_payment_suggestion_handler( + request: Request, + action_id: str, +) -> JSONResponse: + """Record a human rejection without losing the original payment evidence.""" + session, error_response = await _dashboard_session_or_error( + request, + required_permission=DASHBOARD_PERMISSION_CONFIGURATION_WRITE, + ) + if error_response is not None: + return error_response + assert session is not None + csrf_error = _dashboard_same_origin_post_or_error(request) + if csrf_error is not None: + return csrf_error + normalized_action_id = _valid_uuid_or_none(action_id) + if normalized_action_id is None: + await _audit_dashboard_project_payment_suggestion_review( + session, + result=AuditResult.ERROR, + action_id=None, + action="project_payment_suggestion.reject", + metadata={"error": "invalid_action_id"}, + ) + return JSONResponse({"error": "invalid_action_id"}, status_code=400) + payload_error = await _dashboard_project_payment_suggestion_review_payload_or_error( + request + ) + if payload_error is not None: + await _audit_dashboard_project_payment_suggestion_review( + session, + result=AuditResult.ERROR, + action_id=normalized_action_id, + action="project_payment_suggestion.reject", + metadata={"error": "invalid_project_payment_suggestion_review_payload"}, + ) + return payload_error + actor_provider, actor_subject = _session_audit_actor(session) + try: + stored_action = await asyncio.to_thread( + reject_automation_action, + settings, + action_id=normalized_action_id, + rejected_by=f"{actor_provider.value}:{actor_subject}", + ) + except Exception: + logger.exception( + "Failed rejecting project payment suggestion action_id=%s", + normalized_action_id, + ) + await _audit_dashboard_project_payment_suggestion_review( + session, + result=AuditResult.ERROR, + action_id=normalized_action_id, + action="project_payment_suggestion.reject", + metadata={"error": "project_payment_suggestion_rejection_failed"}, + ) + return JSONResponse( + {"error": "project_payment_suggestion_rejection_failed"}, + status_code=500, + ) + if stored_action is None: + await _audit_dashboard_project_payment_suggestion_review( + session, + result=AuditResult.ERROR, + action_id=normalized_action_id, + action="project_payment_suggestion.reject", + metadata={"error": "suggestion_not_awaiting_review"}, + ) + return JSONResponse( + {"error": "suggestion_not_awaiting_review"}, status_code=409 + ) + await _audit_dashboard_project_payment_suggestion_review( + session, + result=AuditResult.SUCCESS, + action_id=stored_action.id, + action="project_payment_suggestion.reject", + metadata={"project_id": stored_action.rule_project_id}, + ) + return JSONResponse( + { + "status": "rejected", + "action": _dashboard_project_payment_action_payload(stored_action), + } + ) + + async def dashboard_newsletter_suppressions_handler( request: Request, limit: int = Query(default=200, ge=1, le=1000), @@ -8226,6 +9107,84 @@ async def docuseal_webhook_handler(request: Request) -> JSONResponse: ) +async def erpnext_bank_transaction_webhook_handler(request: Request) -> JSONResponse: + """Queue a signed ERPNext Bank Transaction reference for canonical ingest. + + Frappe supplies only identity/revision fields. The worker reads the complete + Bank Transaction through the authenticated ERP API before any payment rule + sees it, so this body is never treated as accounting source data. + """ + body = await request.body() + if not _is_erpnext_bank_transaction_webhook_authorized(request, body): + return JSONResponse({"error": "unauthorized"}, status_code=401) + + try: + payload_data = json.loads(body) + except Exception: + return JSONResponse({"error": "invalid_json"}, status_code=400) + if not isinstance(payload_data, dict): + return JSONResponse({"error": "payload_must_be_object"}, status_code=400) + + try: + payload = ERPNextBankTransactionWebhookPayload.model_validate(payload_data) + except (ValidationError, TypeError) as exc: + logger.warning("Rejecting ERPNext Bank Transaction webhook: %s", exc) + return JSONResponse({"error": "invalid_payload"}, status_code=400) + + idempotency_key = f"erpnext-bank-transaction:{payload.name}:{payload.event_type}:{payload.modified}" + try: + job: EnqueuedJob = await asyncio.to_thread( + enqueue_job, + queue=request.app.state.queue, + fn=JOB_FUNCTIONS["ingest_erpnext_bank_transaction_job"], + args=(payload.name, payload.event_type, payload.modified), + settings=settings, + idempotency_key=idempotency_key, + ) + # `enqueue_job` intentionally does not redeliver an existing idempotent + # row. For a financial webhook, a Frappe retry must also repair the + # narrow failure window where Postgres committed but Redis delivery + # failed. The actor safely exits for terminal/running jobs. A prior + # transient outage may also have exhausted the durable job's retry + # budget: atomically revive only that dead ingest job, so concurrent + # duplicate webhooks cannot create multiple recovery attempts. + if not job.created: + existing_job = await asyncio.to_thread(get_job, settings, job.id) + if existing_job is not None and existing_job.status == JobStatus.DEAD: + revived = await asyncio.to_thread( + revive_dead_job, + settings, + job.id, + expected_job_type=JOB_FUNCTIONS[ + "ingest_erpnext_bank_transaction_job" + ].__name__, + ) + if revived: + logger.info( + "Revived dead ERPNext Bank Transaction ingest job_id=%s", + job.id, + ) + await asyncio.to_thread(request.app.state.queue.enqueue, job.id) + else: + await asyncio.to_thread(request.app.state.queue.enqueue, job.id) + except Exception: + logger.exception( + "Failed enqueueing ERPNext Bank Transaction name=%s", payload.name + ) + return JSONResponse({"error": "enqueue_failed"}, status_code=503) + + return JSONResponse( + { + "status": "queued", + "source": "erpnext_bank_transaction", + "job_id": job.id, + "created": job.created, + "transaction_name": payload.name, + }, + status_code=202, + ) + + async def google_forms_intake_webhook_handler(request: Request) -> JSONResponse: """Validate a Google Forms intake submission and enqueue a processing job.""" if not _is_webhook_authorized(request): @@ -9693,6 +10652,13 @@ async def _lifespan(app: FastAPI) -> Any: else: logger.info("508 members newsletter sync scheduler disabled by config") + if settings.project_payment_automation_enabled: + app.state.project_payment_recovery_task = asyncio.create_task( + _project_payment_recovery_scheduler(app) + ) + else: + logger.info("Project payment automation recovery scheduler disabled by config") + if settings.email_resume_intake_enabled: app.state.email_resume_task = asyncio.create_task(_email_resume_scheduler()) else: @@ -9719,6 +10685,12 @@ async def _lifespan(app: FastAPI) -> Any: with contextlib.suppress(asyncio.CancelledError): await task + if hasattr(app.state, "project_payment_recovery_task"): + task = app.state.project_payment_recovery_task + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + if hasattr(app.state, "http_client"): await app.state.http_client.aclose() diff --git a/apps/api/src/five08/backend/dashboard.py b/apps/api/src/five08/backend/dashboard.py index e2f5e576..77b9fcff 100644 --- a/apps/api/src/five08/backend/dashboard.py +++ b/apps/api/src/five08/backend/dashboard.py @@ -15,6 +15,7 @@ /dashboard/people /dashboard/gigs /dashboard/projects +/dashboard/payments /dashboard/onboarding /dashboard/jobs /dashboard/agent @@ -375,7 +376,7 @@ def dashboard_html() -> str:

508 Operations Dashboard

The dashboard frontend bundle has not been built. Run bun run build in apps/admin_dashboard.

- + """ diff --git a/apps/api/src/five08/backend/routes.py b/apps/api/src/five08/backend/routes.py index f999de6f..05bb2c2c 100644 --- a/apps/api/src/five08/backend/routes.py +++ b/apps/api/src/five08/backend/routes.py @@ -32,11 +32,13 @@ class BackendRouteSurface(Protocol): dashboard_add_project_historical_member_handler: RouteHandler dashboard_add_project_user_handler: RouteHandler dashboard_agent_report_handler: RouteHandler + dashboard_approve_project_payment_suggestion_handler: RouteHandler dashboard_assign_onboarder_handler: RouteHandler dashboard_audit_events_handler: RouteHandler dashboard_bulk_update_projects_handler: RouteHandler dashboard_configuration_handler: RouteHandler dashboard_create_project_handler: RouteHandler + dashboard_create_project_payment_rule_handler: RouteHandler dashboard_erpnext_account_managers_handler: RouteHandler dashboard_erpnext_contacts_handler: RouteHandler dashboard_erpnext_cost_centers_handler: RouteHandler @@ -49,6 +51,7 @@ class BackendRouteSurface(Protocol): dashboard_review_job_lead_handler: RouteHandler dashboard_job_detail_handler: RouteHandler dashboard_delete_job_channel_handler: RouteHandler + dashboard_disable_project_payment_rule_handler: RouteHandler dashboard_job_channels_handler: RouteHandler dashboard_jobs_handler: RouteHandler dashboard_me_handler: RouteHandler @@ -60,10 +63,13 @@ class BackendRouteSurface(Protocol): dashboard_onboarding_handler: RouteHandler dashboard_people_handler: RouteHandler dashboard_project_member_candidates_handler: RouteHandler + dashboard_project_payment_rules_handler: RouteHandler + dashboard_project_payment_suggestions_handler: RouteHandler dashboard_project_wiki_matches_handler: RouteHandler dashboard_projects_handler: RouteHandler dashboard_remove_project_historical_member_handler: RouteHandler dashboard_remove_project_user_handler: RouteHandler + dashboard_reject_project_payment_suggestion_handler: RouteHandler dashboard_rerun_job_handler: RouteHandler dashboard_setup_engineer_handler: RouteHandler dashboard_sync_newsletters_handler: RouteHandler @@ -76,8 +82,10 @@ class BackendRouteSurface(Protocol): dashboard_update_job_channel_handler: RouteHandler dashboard_update_onboarding_status_handler: RouteHandler dashboard_update_project_status_handler: RouteHandler + dashboard_update_project_payment_rule_handler: RouteHandler dashboard_update_project_wiki_match_handler: RouteHandler docuseal_webhook_handler: RouteHandler + erpnext_bank_transaction_webhook_handler: RouteHandler espocrm_people_sync_webhook_handler: RouteHandler espocrm_webhook_handler: RouteHandler google_forms_intake_webhook_handler: RouteHandler @@ -112,12 +120,18 @@ def register_routes(app: FastAPI, api: BackendRouteSurface) -> None: ) dashboard_add_project_user_handler = api.dashboard_add_project_user_handler dashboard_agent_report_handler = api.dashboard_agent_report_handler + dashboard_approve_project_payment_suggestion_handler = ( + api.dashboard_approve_project_payment_suggestion_handler + ) dashboard_assets_dir = api.dashboard_assets_dir dashboard_assign_onboarder_handler = api.dashboard_assign_onboarder_handler dashboard_audit_events_handler = api.dashboard_audit_events_handler dashboard_bulk_update_projects_handler = api.dashboard_bulk_update_projects_handler dashboard_configuration_handler = api.dashboard_configuration_handler dashboard_create_project_handler = api.dashboard_create_project_handler + dashboard_create_project_payment_rule_handler = ( + api.dashboard_create_project_payment_rule_handler + ) dashboard_erpnext_account_managers_handler = ( api.dashboard_erpnext_account_managers_handler ) @@ -128,6 +142,9 @@ def register_routes(app: FastAPI, api: BackendRouteSurface) -> None: dashboard_gigs_handler = api.dashboard_gigs_handler dashboard_handler = api.dashboard_handler dashboard_delete_job_channel_handler = api.dashboard_delete_job_channel_handler + dashboard_disable_project_payment_rule_handler = ( + api.dashboard_disable_project_payment_rule_handler + ) dashboard_job_leads_handler = api.dashboard_job_leads_handler dashboard_job_channels_handler = api.dashboard_job_channels_handler dashboard_post_job_lead_handler = api.dashboard_post_job_lead_handler @@ -151,12 +168,21 @@ def register_routes(app: FastAPI, api: BackendRouteSurface) -> None: dashboard_project_member_candidates_handler = ( api.dashboard_project_member_candidates_handler ) + dashboard_project_payment_rules_handler = ( + api.dashboard_project_payment_rules_handler + ) + dashboard_project_payment_suggestions_handler = ( + api.dashboard_project_payment_suggestions_handler + ) dashboard_project_wiki_matches_handler = api.dashboard_project_wiki_matches_handler dashboard_projects_handler = api.dashboard_projects_handler dashboard_remove_project_historical_member_handler = ( api.dashboard_remove_project_historical_member_handler ) dashboard_remove_project_user_handler = api.dashboard_remove_project_user_handler + dashboard_reject_project_payment_suggestion_handler = ( + api.dashboard_reject_project_payment_suggestion_handler + ) dashboard_rerun_job_handler = api.dashboard_rerun_job_handler dashboard_setup_engineer_handler = api.dashboard_setup_engineer_handler dashboard_sync_newsletters_handler = api.dashboard_sync_newsletters_handler @@ -175,10 +201,16 @@ def register_routes(app: FastAPI, api: BackendRouteSurface) -> None: dashboard_update_project_status_handler = ( api.dashboard_update_project_status_handler ) + dashboard_update_project_payment_rule_handler = ( + api.dashboard_update_project_payment_rule_handler + ) dashboard_update_project_wiki_match_handler = ( api.dashboard_update_project_wiki_match_handler ) docuseal_webhook_handler = api.docuseal_webhook_handler + erpnext_bank_transaction_webhook_handler = ( + api.erpnext_bank_transaction_webhook_handler + ) espocrm_people_sync_webhook_handler = api.espocrm_people_sync_webhook_handler espocrm_webhook_handler = api.espocrm_webhook_handler google_forms_intake_webhook_handler = api.google_forms_intake_webhook_handler @@ -426,6 +458,41 @@ def register_routes(app: FastAPI, api: BackendRouteSurface) -> None: dashboard_update_configuration_handler, methods=["PUT"], ) + app.add_api_route( + "/dashboard/api/project-payment-rules", + dashboard_project_payment_rules_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/project-payment-rules", + dashboard_create_project_payment_rule_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/project-payment-rules/{rule_id}", + dashboard_update_project_payment_rule_handler, + methods=["PUT"], + ) + app.add_api_route( + "/dashboard/api/project-payment-rules/{rule_id}", + dashboard_disable_project_payment_rule_handler, + methods=["DELETE"], + ) + app.add_api_route( + "/dashboard/api/project-payment-suggestions", + dashboard_project_payment_suggestions_handler, + methods=["GET"], + ) + app.add_api_route( + "/dashboard/api/project-payment-suggestions/{action_id}/approve", + dashboard_approve_project_payment_suggestion_handler, + methods=["POST"], + ) + app.add_api_route( + "/dashboard/api/project-payment-suggestions/{action_id}/reject", + dashboard_reject_project_payment_suggestion_handler, + methods=["POST"], + ) app.add_api_route( "/dashboard/api/newsletter/suppressions", dashboard_newsletter_suppressions_handler, @@ -491,6 +558,11 @@ def register_routes(app: FastAPI, api: BackendRouteSurface) -> None: tally_intake_webhook_handler, methods=["POST"], ) + app.add_api_route( + "/webhooks/erpnext/bank-transaction", + erpnext_bank_transaction_webhook_handler, + methods=["POST"], + ) app.add_api_route("/webhooks/{source}", ingest_handler, methods=["POST"]) app.add_api_route( diff --git a/apps/api/src/five08/backend/schemas.py b/apps/api/src/five08/backend/schemas.py index 2af1b305..446b2f57 100644 --- a/apps/api/src/five08/backend/schemas.py +++ b/apps/api/src/five08/backend/schemas.py @@ -4,9 +4,14 @@ from typing import Any, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from five08.agent import AgentIdentityContext +from five08.automation import ( + AutomationAction, + AutomationCondition, + AutomationRuleMode, +) class ResumeExtractRequest(BaseModel): @@ -218,3 +223,38 @@ class DashboardConfigurationUpdateRequest(BaseModel): value: str | bool | int | float | None = None clear: bool = False + + +class DashboardProjectPaymentRuleCreateRequest(BaseModel): + """Operator-managed declarative rule for one ERP bank receipt project.""" + + project_id: str = Field(min_length=1, max_length=64) + priority: int = Field(default=0, ge=-10_000, le=10_000) + mode: AutomationRuleMode = AutomationRuleMode.SUGGEST + enabled: bool = True + conditions: list[AutomationCondition] = Field(default_factory=list) + actions: list[AutomationAction] = Field(min_length=1) + + +class DashboardProjectPaymentRuleUpdateRequest( + DashboardProjectPaymentRuleCreateRequest +): + """Version-checked replacement for one declarative payment rule.""" + + expected_version: int = Field(ge=1) + + +class DashboardProjectPaymentRuleDisableRequest(BaseModel): + """Version-checked soft disable to retain payment rule audit history.""" + + expected_version: int = Field(ge=1) + + +class DashboardProjectPaymentSuggestionReviewRequest(BaseModel): + """Empty acknowledgement for a server-side suggestion state transition. + + The action id and decision are deliberately supplied by the route, rather + than trusting a client-controlled target, amount, or project payload. + """ + + model_config = ConfigDict(extra="forbid") diff --git a/apps/api/src/five08/backend/static/dashboard/.vite/manifest.json b/apps/api/src/five08/backend/static/dashboard/.vite/manifest.json index a34c81b1..44bfdfce 100644 --- a/apps/api/src/five08/backend/static/dashboard/.vite/manifest.json +++ b/apps/api/src/five08/backend/static/dashboard/.vite/manifest.json @@ -1,11 +1,11 @@ { "index.html": { - "file": "assets/index-CjyViga_.js", + "file": "assets/index-BT3M2qFq.js", "name": "index", "src": "index.html", "isEntry": true, "css": [ - "assets/index-2UypYUrJ.css" + "assets/index-C3gljehK.css" ] } } \ No newline at end of file diff --git a/apps/api/src/five08/backend/static/dashboard/assets/index-2UypYUrJ.css b/apps/api/src/five08/backend/static/dashboard/assets/index-2UypYUrJ.css deleted file mode 100644 index dc3a6668..00000000 --- a/apps/api/src/five08/backend/static/dashboard/assets/index-2UypYUrJ.css +++ /dev/null @@ -1,2 +0,0 @@ -/*! tailwindcss v4.3.1 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-100:oklch(93.6% .032 17.717);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-700:oklch(50.5% .213 27.518);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-teal-200:oklch(91% .096 180.426);--color-teal-400:oklch(77.7% .152 181.912);--color-teal-500:oklch(70.4% .14 182.503);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--leading-tight:1.25;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--border);outline-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){*{outline-color:color-mix(in oklab, var(--ring) 50%, transparent)}}body{min-width:calc(var(--spacing) * 80);background-color:var(--background);color:var(--foreground);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}button{cursor:pointer}button:disabled{cursor:not-allowed}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.-top-1{top:calc(var(--spacing) * -1)}.top-0{top:0}.-right-1{right:calc(var(--spacing) * -1)}.right-0{right:0}.right-5{right:calc(var(--spacing) * 5)}.bottom-5{bottom:calc(var(--spacing) * 5)}.left-5{left:calc(var(--spacing) * 5)}.z-20{z-index:20}.z-40{z-index:40}.z-50{z-index:50}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.m-0{margin:0}.mx-auto{margin-inline:auto}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.ml-2{margin-left:calc(var(--spacing) * 2)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.table-row{display:table-row}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-full{height:100%}.max-h-20{max-height:calc(var(--spacing) * 20)}.max-h-40{max-height:calc(var(--spacing) * 40)}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-56{max-height:calc(var(--spacing) * 56)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-\[78vh\]{max-height:78vh}.max-h-\[90vh\]{max-height:90vh}.min-h-0{min-height:0}.min-h-5{min-height:calc(var(--spacing) * 5)}.min-h-7{min-height:calc(var(--spacing) * 7)}.min-h-8{min-height:calc(var(--spacing) * 8)}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-10{min-height:calc(var(--spacing) * 10)}.min-h-11{min-height:calc(var(--spacing) * 11)}.min-h-20{min-height:calc(var(--spacing) * 20)}.min-h-64{min-height:calc(var(--spacing) * 64)}.min-h-\[22px\]{min-height:22px}.w-\[10\%\]{width:10%}.w-\[11\%\]{width:11%}.w-\[12\%\]{width:12%}.w-\[14\%\]{width:14%}.w-\[15\%\]{width:15%}.w-\[16\%\]{width:16%}.w-\[18\%\]{width:18%}.w-\[20\%\]{width:20%}.w-\[22\%\]{width:22%}.w-\[24\%\]{width:24%}.w-\[25\%\]{width:25%}.w-\[26\%\]{width:26%}.w-\[27\%\]{width:27%}.w-\[28\%\]{width:28%}.w-\[29\%\]{width:29%}.w-\[32\%\]{width:32%}.w-\[34\%\]{width:34%}.w-\[48px\]{width:48px}.w-\[160px\]{width:160px}.w-\[min\(48rem\,calc\(100vw-2\.5rem\)\)\]{width:min(48rem,100vw - 2.5rem)}.w-fit{width:fit-content}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-40{max-width:calc(var(--spacing) * 40)}.max-w-56{max-width:calc(var(--spacing) * 56)}.max-w-64{max-width:calc(var(--spacing) * 64)}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.min-w-0{min-width:0}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-44{min-width:calc(var(--spacing) * 44)}.min-w-\[190px\]{min-width:190px}.min-w-\[760px\]{min-width:760px}.min-w-\[860px\]{min-width:860px}.min-w-\[900px\]{min-width:900px}.min-w-\[920px\]{min-width:920px}.min-w-\[980px\]{min-width:980px}.min-w-\[1100px\]{min-width:1100px}.min-w-\[1340px\]{min-width:1340px}.shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.scroll-mt-4{scroll-margin-top:calc(var(--spacing) * 4)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[minmax\(100px\,1fr\)_auto\]{grid-template-columns:minmax(100px,1fr) auto}.grid-rows-\[auto_minmax\(0\,1fr\)\]{grid-template-rows:auto minmax(0,1fr)}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.content-start{align-content:flex-start}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-y-1{row-gap:var(--spacing)}.self-end{align-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-400\/40{border-color:#fcbb0066}@supports (color:color-mix(in lab, red, red)){.border-amber-400\/40{border-color:color-mix(in oklab, var(--color-amber-400) 40%, transparent)}}.border-amber-500\/40{border-color:#f99c0066}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/40{border-color:color-mix(in oklab, var(--color-amber-500) 40%, transparent)}}.border-border{border-color:var(--border)}.border-destructive{border-color:var(--destructive)}.border-emerald-400\/35{border-color:#00d29459}@supports (color:color-mix(in lab, red, red)){.border-emerald-400\/35{border-color:color-mix(in oklab, var(--color-emerald-400) 35%, transparent)}}.border-emerald-500\/40{border-color:#00bb7f66}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/40{border-color:color-mix(in oklab, var(--color-emerald-500) 40%, transparent)}}.border-input{border-color:var(--input)}.border-primary{border-color:var(--primary)}.border-red-400\/40{border-color:#ff656866}@supports (color:color-mix(in lab, red, red)){.border-red-400\/40{border-color:color-mix(in oklab, var(--color-red-400) 40%, transparent)}}.border-red-500\/25{border-color:#fb2c3640}@supports (color:color-mix(in lab, red, red)){.border-red-500\/25{border-color:color-mix(in oklab, var(--color-red-500) 25%, transparent)}}.border-red-500\/40{border-color:#fb2c3666}@supports (color:color-mix(in lab, red, red)){.border-red-500\/40{border-color:color-mix(in oklab, var(--color-red-500) 40%, transparent)}}.border-teal-400\/40{border-color:#00d3bd66}@supports (color:color-mix(in lab, red, red)){.border-teal-400\/40{border-color:color-mix(in oklab, var(--color-teal-400) 40%, transparent)}}.border-transparent{border-color:#0000}.border-l-muted-foreground\/60{border-left-color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){.border-l-muted-foreground\/60{border-left-color:color-mix(in oklab, var(--muted-foreground) 60%, transparent)}}.bg-accent{background-color:var(--accent)}.bg-amber-200{background-color:var(--color-amber-200)}.bg-amber-500\/15{background-color:#f99c0026}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/15{background-color:color-mix(in oklab, var(--color-amber-500) 15%, transparent)}}.bg-background,.bg-background\/40{background-color:var(--background)}@supports (color:color-mix(in lab, red, red)){.bg-background\/40{background-color:color-mix(in oklab, var(--background) 40%, transparent)}}.bg-background\/90{background-color:var(--background)}@supports (color:color-mix(in lab, red, red)){.bg-background\/90{background-color:color-mix(in oklab, var(--background) 90%, transparent)}}.bg-black\/20{background-color:#0003}@supports (color:color-mix(in lab, red, red)){.bg-black\/20{background-color:color-mix(in oklab, var(--color-black) 20%, transparent)}}.bg-black\/30{background-color:#0000004d}@supports (color:color-mix(in lab, red, red)){.bg-black\/30{background-color:color-mix(in oklab, var(--color-black) 30%, transparent)}}.bg-black\/45{background-color:#00000073}@supports (color:color-mix(in lab, red, red)){.bg-black\/45{background-color:color-mix(in oklab, var(--color-black) 45%, transparent)}}.bg-card{background-color:var(--card)}.bg-destructive{background-color:var(--destructive)}.bg-emerald-500\/15{background-color:#00bb7f26}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/15{background-color:color-mix(in oklab, var(--color-emerald-500) 15%, transparent)}}.bg-primary{background-color:var(--primary)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/5{background-color:#fb2c360d}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/5{background-color:color-mix(in oklab, var(--color-red-500) 5%, transparent)}}.bg-red-500\/15{background-color:#fb2c3626}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/15{background-color:color-mix(in oklab, var(--color-red-500) 15%, transparent)}}.bg-secondary,.bg-secondary\/30{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.bg-secondary\/30{background-color:color-mix(in oklab, var(--secondary) 30%, transparent)}}.bg-secondary\/35{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.bg-secondary\/35{background-color:color-mix(in oklab, var(--secondary) 35%, transparent)}}.bg-secondary\/45{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.bg-secondary\/45{background-color:color-mix(in oklab, var(--secondary) 45%, transparent)}}.bg-teal-500\/15{background-color:#00baa726}@supports (color:color-mix(in lab, red, red)){.bg-teal-500\/15{background-color:color-mix(in oklab, var(--color-teal-500) 15%, transparent)}}.p-0{padding:0}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:var(--spacing)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-7{padding-block:calc(var(--spacing) * 7)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-\[inherit\]{font-family:inherit}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[11px\]{font-size:11px}.text-\[15px\]{font-size:15px}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-accent-foreground{color:var(--accent-foreground)}.text-amber-200{color:var(--color-amber-200)}.text-amber-300{color:var(--color-amber-300)}.text-card-foreground{color:var(--card-foreground)}.text-destructive{color:var(--destructive)}.text-emerald-300{color:var(--color-emerald-300)}.text-foreground{color:var(--foreground)}.text-inherit{color:inherit}.text-muted-foreground{color:var(--muted-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-red-100{color:var(--color-red-100)}.text-red-300{color:var(--color-red-300)}.text-red-700{color:var(--color-red-700)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-teal-200{color:var(--color-teal-200)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.underline-offset-4{text-underline-offset:4px}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_18px_44px_rgb\(0_0_0\/0\.22\)\]{--tw-shadow:0 18px 44px var(--tw-shadow-color,#00000038);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-primary{--tw-ring-color:var(--primary)}.ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ring-offset-background{--tw-ring-offset-color:var(--background)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.running{animation-play-state:running}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}@media (hover:hover){.hover\:border-border:hover{border-color:var(--border)}.hover\:border-primary:hover{border-color:var(--primary)}.hover\:bg-accent:hover{background-color:var(--accent)}.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab, var(--destructive) 90%, transparent)}}.hover\:bg-muted\/45:hover{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-muted\/45:hover{background-color:color-mix(in oklab, var(--muted) 45%, transparent)}}.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab, var(--primary) 90%, transparent)}}.hover\:bg-secondary:hover,.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab, var(--secondary) 80%, transparent)}}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:bg-secondary:focus{background-color:var(--secondary)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (width>=40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (width>=48rem){.md\:sticky{position:sticky}.md\:top-24{top:calc(var(--spacing) * 24)}.md\:col-span-2{grid-column:span 2/span 2}.md\:col-span-4{grid-column:span 4/span 4}.md\:col-span-5{grid-column:span 5/span 5}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.md\:grid-cols-\[7rem_minmax\(0\,1fr\)\]{grid-template-columns:7rem minmax(0,1fr)}.md\:grid-cols-\[190px_minmax\(0\,1fr\)\]{grid-template-columns:190px minmax(0,1fr)}.md\:grid-cols-\[auto_minmax\(0\,1fr\)_auto\]{grid-template-columns:auto minmax(0,1fr) auto}.md\:grid-cols-\[auto_minmax\(150px\,220px\)_minmax\(150px\,220px\)_auto\]{grid-template-columns:auto minmax(150px,220px) minmax(150px,220px) auto}.md\:grid-cols-\[minmax\(0\,1fr\)_180px_auto_auto_auto\]{grid-template-columns:minmax(0,1fr) 180px auto auto auto}.md\:grid-cols-\[minmax\(0\,1fr\)_auto\]{grid-template-columns:minmax(0,1fr) auto}.md\:grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1fr\)_minmax\(130px\,\.6fr\)\]{grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(130px,.6fr)}.md\:grid-cols-\[minmax\(0\,1fr\)_minmax\(130px\,\.6fr\)\]{grid-template-columns:minmax(0,1fr) minmax(130px,.6fr)}.md\:grid-cols-\[minmax\(120px\,\.7fr\)_minmax\(150px\,1fr\)_minmax\(150px\,1fr\)_auto\]{grid-template-columns:minmax(120px,.7fr) minmax(150px,1fr) minmax(150px,1fr) auto}.md\:grid-cols-\[minmax\(140px\,\.8fr\)_minmax\(150px\,1fr\)_minmax\(150px\,1fr\)_minmax\(120px\,\.7fr\)_auto\]{grid-template-columns:minmax(140px,.8fr) minmax(150px,1fr) minmax(150px,1fr) minmax(120px,.7fr) auto}.md\:grid-cols-\[minmax\(140px\,\.75fr\)_minmax\(220px\,1\.25fr\)_auto_auto_auto\]{grid-template-columns:minmax(140px,.75fr) minmax(220px,1.25fr) auto auto auto}.md\:grid-cols-\[minmax\(160px\,\.6fr\)_auto_auto\]{grid-template-columns:minmax(160px,.6fr) auto auto}.md\:grid-cols-\[minmax\(220px\,1fr\)_auto\]{grid-template-columns:minmax(220px,1fr) auto}.md\:grid-cols-\[minmax\(260px\,1fr\)_minmax\(180px\,\.7fr\)_minmax\(130px\,\.45fr\)_minmax\(130px\,\.45fr\)_auto_auto\]{grid-template-columns:minmax(260px,1fr) minmax(180px,.7fr) minmax(130px,.45fr) minmax(130px,.45fr) auto auto}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:items-end{align-items:flex-end}.md\:items-start{align-items:flex-start}.md\:justify-between{justify-content:space-between}.md\:justify-end{justify-content:flex-end}}@media (width>=64rem){.lg\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[1fr_1fr_1fr\]{grid-template-columns:1fr 1fr 1fr}.lg\:grid-cols-\[minmax\(0\,1\.5fr\)_minmax\(180px\,0\.7fr\)_auto\]{grid-template-columns:minmax(0,1.5fr) minmax(180px,.7fr) auto}.lg\:grid-cols-\[minmax\(0\,1fr\)_220px_180px\]{grid-template-columns:minmax(0,1fr) 220px 180px}.lg\:grid-cols-\[minmax\(0\,1fr\)_220px_190px\]{grid-template-columns:minmax(0,1fr) 220px 190px}.lg\:items-start{align-items:flex-start}}.dark\:bg-amber-500\/35:is(.dark *){background-color:#f99c0059}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-500\/35:is(.dark *){background-color:color-mix(in oklab, var(--color-amber-500) 35%, transparent)}}.dark\:text-red-300:is(.dark *){color:var(--color-red-300)}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}:root{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--radius:.5rem;--background:oklch(16% .006 160);--foreground:oklch(94% .008 125);--card:oklch(22% .007 155);--card-foreground:oklch(94% .008 125);--popover:oklch(22% .007 155);--popover-foreground:oklch(94% .008 125);--primary:oklch(73% .11 178);--primary-foreground:oklch(17% .018 178);--secondary:oklch(28% .012 155);--secondary-foreground:oklch(94% .008 125);--muted:oklch(28% .012 155);--muted-foreground:oklch(71% .016 135);--accent:oklch(30% .042 178);--accent-foreground:oklch(88% .064 178);--destructive:oklch(67% .18 23);--border:oklch(100% 0 0/.14);--input:oklch(100% 0 0/.16);--ring:oklch(73% .11 178)}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false} diff --git a/apps/api/src/five08/backend/static/dashboard/assets/index-BT3M2qFq.js b/apps/api/src/five08/backend/static/dashboard/assets/index-BT3M2qFq.js new file mode 100644 index 00000000..7f633eb7 --- /dev/null +++ b/apps/api/src/five08/backend/static/dashboard/assets/index-BT3M2qFq.js @@ -0,0 +1,9 @@ +var e=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports);(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var t=e((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var S=Array.isArray;function ee(){}var C={H:null,A:null,T:null,S:null},w=Object.prototype.hasOwnProperty;function te(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function ne(e,t){return te(e.type,t,e.props)}function T(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function re(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var ie=/\/+/g;function ae(e,t){return typeof e==`object`&&e&&e.key!=null?re(``+e.key):t.toString(36)}function oe(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(ee,ee):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function E(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,E(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+ae(e,0):a,S(o)?(i=``,c!=null&&(i=c.replace(ie,`$&/`)+`/`),E(o,r,i,``,function(e){return e})):o!=null&&(T(o)&&(o=ne(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(ie,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(S(e))for(var u=0;u{n.exports=t()})),r=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),i=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),a=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(e,t,n)=>n?n.toUpperCase():t.toLowerCase()),o=e=>{let t=a(e);return t.charAt(0).toUpperCase()+t.slice(1)},s={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},c=e=>{for(let t in e)if(t.startsWith(`aria-`)||t===`role`||t===`title`)return!0;return!1},l=n(),u=(0,l.createContext)({}),d=()=>(0,l.useContext)(u),f=(0,l.forwardRef)(({color:e,size:t,strokeWidth:n,absoluteStrokeWidth:i,className:a=``,children:o,iconNode:u,...f},p)=>{let{size:m=24,strokeWidth:h=2,absoluteStrokeWidth:g=!1,color:_=`currentColor`,className:v=``}=d()??{},y=i??g?Number(n??h)*24/Number(t??m):n??h;return(0,l.createElement)(`svg`,{ref:p,...s,width:t??m??s.width,height:t??m??s.height,stroke:e??_,strokeWidth:y,className:r(`lucide`,v,a),...!o&&!c(f)&&{"aria-hidden":`true`},...f},[...u.map(([e,t])=>(0,l.createElement)(e,t)),...Array.isArray(o)?o:[o]])}),p=(e,t)=>{let n=(0,l.forwardRef)(({className:n,...a},s)=>(0,l.createElement)(f,{ref:s,iconNode:t,className:r(`lucide-${i(o(e))}`,`lucide-${e}`,n),...a}));return n.displayName=o(e),n},m=p(`activity`,[[`path`,{d:`M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2`,key:`169zse`}]]),h=p(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]),g=p(`bell`,[[`path`,{d:`M10.268 21a2 2 0 0 0 3.464 0`,key:`vwvbt9`}],[`path`,{d:`M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326`,key:`11g9vi`}]]),_=p(`briefcase-business`,[[`path`,{d:`M12 12h.01`,key:`1mp3jc`}],[`path`,{d:`M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2`,key:`1ksdt3`}],[`path`,{d:`M22 13a18.15 18.15 0 0 1-20 0`,key:`12hx5q`}],[`rect`,{width:`20`,height:`14`,x:`2`,y:`6`,rx:`2`,key:`i6l2r4`}]]),v=p(`check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]),y=p(`clipboard-list`,[[`rect`,{width:`8`,height:`4`,x:`8`,y:`2`,rx:`1`,ry:`1`,key:`tgr4d6`}],[`path`,{d:`M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2`,key:`116196`}],[`path`,{d:`M12 11h4`,key:`1jrz19`}],[`path`,{d:`M12 16h4`,key:`n85exb`}],[`path`,{d:`M8 11h.01`,key:`1dfujw`}],[`path`,{d:`M8 16h.01`,key:`18s6g9`}]]),b=p(`external-link`,[[`path`,{d:`M15 3h6v6`,key:`1q9fwt`}],[`path`,{d:`M10 14 21 3`,key:`gplh6r`}],[`path`,{d:`M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6`,key:`a6xqqp`}]]),x=p(`file-clock`,[[`path`,{d:`M16 22h2a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v2.85`,key:`ryk6xj`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`M8 14v2.2l1.6 1`,key:`6m4bie`}],[`circle`,{cx:`8`,cy:`16`,r:`6`,key:`10v15b`}]]),S=p(`folder-kanban`,[[`path`,{d:`M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z`,key:`1fr9dc`}],[`path`,{d:`M8 10v4`,key:`tgpxqk`}],[`path`,{d:`M12 10v2`,key:`hh53o1`}],[`path`,{d:`M16 10v6`,key:`1d6xys`}]]),ee=p(`lightbulb`,[[`path`,{d:`M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5`,key:`1gvzjb`}],[`path`,{d:`M9 18h6`,key:`x1upvd`}],[`path`,{d:`M10 22h4`,key:`ceow96`}]]),C=p(`log-out`,[[`path`,{d:`m16 17 5-5-5-5`,key:`1bji2h`}],[`path`,{d:`M21 12H9`,key:`dn1m92`}],[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}]]),w=p(`mail`,[[`path`,{d:`m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7`,key:`132q7q`}],[`rect`,{x:`2`,y:`4`,width:`20`,height:`16`,rx:`2`,key:`izxlao`}]]),te=p(`plus`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]]),ne=p(`refresh-cw`,[[`path`,{d:`M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8`,key:`v9h5vc`}],[`path`,{d:`M21 3v5h-5`,key:`1q7to0`}],[`path`,{d:`M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16`,key:`3uifl3`}],[`path`,{d:`M8 16H3v5`,key:`1cv678`}]]),T=p(`search`,[[`path`,{d:`m21 21-4.34-4.34`,key:`14j7rj`}],[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}]]),re=p(`send`,[[`path`,{d:`M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z`,key:`1ffxy3`}],[`path`,{d:`m21.854 2.147-10.94 10.939`,key:`12cjpa`}]]),ie=p(`settings`,[[`path`,{d:`M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915`,key:`1i5ecw`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]]),ae=p(`shield-check`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),oe=p(`trash-2`,[[`path`,{d:`M10 11v6`,key:`nco0om`}],[`path`,{d:`M14 11v6`,key:`outv1u`}],[`path`,{d:`M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6`,key:`miytrc`}],[`path`,{d:`M3 6h18`,key:`d0wm0j`}],[`path`,{d:`M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2`,key:`e791ji`}]]),E=p(`user-minus`,[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}],[`line`,{x1:`22`,x2:`16`,y1:`11`,y2:`11`,key:`1shjgl`}]]),D=p(`user-plus`,[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}],[`line`,{x1:`19`,x2:`19`,y1:`8`,y2:`14`,key:`1bvyxn`}],[`line`,{x1:`22`,x2:`16`,y1:`11`,y2:`11`,key:`1shjgl`}]]),O=p(`users`,[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`path`,{d:`M16 3.128a4 4 0 0 1 0 7.744`,key:`16gr8j`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]]),k=p(`x`,[[`path`,{d:`M18 6 6 18`,key:`1bl5f8`}],[`path`,{d:`m6 6 12 12`,key:`d8bk6v`}]]),se=e((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,S||(S=!0,T());else{var t=n(l);t!==null&&ae(x,t.startTime-e)}}var S=!1,ee=-1,C=5,w=-1;function te(){return g?!0:!(e.unstable_now()-wt&&te());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&ae(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?T():S=!1}}}var T;if(typeof y==`function`)T=function(){y(ne)};else if(typeof MessageChannel<`u`){var re=new MessageChannel,ie=re.port2;re.port1.onmessage=ne,T=function(){ie.postMessage(null)}}else T=function(){_(ne,0)};function ae(t,n){ee=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(ee),ee=-1):h=!0,ae(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,S||(S=!0,T()))),r},e.unstable_shouldYield=te,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),ce=e(((e,t)=>{t.exports=se()})),le=e((e=>{var t=n();function r(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=le()})),A=e((e=>{var t=ce(),r=n(),i=ue();function a(e){var t=`https://react.dev/errors/`+e;if(1le||(e.current=se[le],se[le]=null,le--)}function M(e,t){le++,se[le]=e.current,e.current=t}var de=A(null),fe=A(null),N=A(null),pe=A(null);function me(e,t){switch(M(N,t),M(fe,e),M(de,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Vd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Vd(t),e=Hd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}j(de),M(de,e)}function he(){j(de),j(fe),j(N)}function ge(e){e.memoizedState!==null&&M(pe,e);var t=de.current,n=Hd(t,e.type);t!==n&&(M(fe,e),M(de,n))}function _e(e){fe.current===e&&(j(de),j(fe)),pe.current===e&&(j(pe),Qf._currentValue=k)}var ve,ye;function be(e){if(ve===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);ve=t&&t[1]||``,ye=-1)`:-1i||c[r]!==l[i]){var u=` +`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{xe=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?be(n):``}function Ce(e,t){switch(e.tag){case 26:case 27:case 5:return be(e.type);case 16:return be(`Lazy`);case 13:return e.child!==t&&t!==null?be(`Suspense Fallback`):be(`Suspense`);case 19:return be(`SuspenseList`);case 0:case 15:return Se(e.type,!1);case 11:return Se(e.type.render,!1);case 1:return Se(e.type,!0);case 31:return be(`Activity`);default:return``}}function we(e){try{var t=``,n=null;do t+=Ce(e,n),n=e,e=e.return;while(e);return t}catch(e){return` +Error generating stack: `+e.message+` +`+e.stack}}var Te=Object.prototype.hasOwnProperty,Ee=t.unstable_scheduleCallback,De=t.unstable_cancelCallback,Oe=t.unstable_shouldYield,ke=t.unstable_requestPaint,Ae=t.unstable_now,je=t.unstable_getCurrentPriorityLevel,Me=t.unstable_ImmediatePriority,Ne=t.unstable_UserBlockingPriority,Pe=t.unstable_NormalPriority,Fe=t.unstable_LowPriority,Ie=t.unstable_IdlePriority,Le=t.log,Re=t.unstable_setDisableYieldValue,ze=null,Be=null;function Ve(e){if(typeof Le==`function`&&Re(e),Be&&typeof Be.setStrictMode==`function`)try{Be.setStrictMode(ze,e)}catch{}}var He=Math.clz32?Math.clz32:Ge,Ue=Math.log,We=Math.LN2;function Ge(e){return e>>>=0,e===0?32:31-(Ue(e)/We|0)|0}var Ke=256,qe=262144,Je=4194304;function Ye(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Xe(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=Ye(n))):i=Ye(o):i=Ye(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=Ye(n))):i=Ye(o)):i=Ye(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function Ze(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Qe(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function $e(){var e=Je;return Je<<=1,!(Je&62914560)&&(Je=4194304),e}function et(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function tt(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function nt(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),Qt=!1;if(Zt)try{var $t={};Object.defineProperty($t,"passive",{get:function(){Qt=!0}}),window.addEventListener(`test`,$t,$t),window.removeEventListener(`test`,$t,$t)}catch{Qt=!1}var en=null,tn=null,nn=null;function rn(){if(nn)return nn;var e,t=tn,n=t.length,r,i=`value`in en?en.value:en.textContent,a=i.length;for(e=0;e=In),zn=` `,Bn=!1;function Vn(e,t){switch(e){case`keyup`:return Pn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function Hn(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var Un=!1;function Wn(e,t){switch(e){case`compositionend`:return Hn(t);case`keypress`:return t.which===32?(Bn=!0,zn):null;case`textInput`:return e=t.data,e===zn&&Bn?null:e;default:return null}}function Gn(e,t){if(Un)return e===`compositionend`||!Fn&&Vn(e,t)?(e=rn(),nn=tn=en=null,Un=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=fr(n)}}function mr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?mr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function hr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Rt(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Rt(e.document)}return t}function gr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var _r=Zt&&`documentMode`in document&&11>=document.documentMode,vr=null,yr=null,br=null,xr=!1;function Sr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;xr||vr==null||vr!==Rt(r)||(r=vr,`selectionStart`in r&&gr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),br&&dr(br,r)||(br=r,r=Td(yr,`onSelect`),0>=o,i-=o,mi=1<<32-He(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),Ci&&gi(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(i,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(i,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(i,h),Ci&&gi(i,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(i,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return Ci&&gi(i,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,i,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(i,e)}),Ci&&gi(i,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===_&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case h:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===_){if(r.tag===7){n(e,r.sibling),c=i(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===te&&_a(l)===r.type){n(e,r.sibling),c=i(r,o.props),wa(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===_?(c=ti(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=ei(o.type,o.key,o.props,null,e.mode,c),wa(c,o),c.return=e,e=c)}return s(e);case g:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=i(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=ii(o,e.mode,c),c.return=e,e=c}return s(e);case te:return o=_a(o),b(e,r,o,c)}if(E(o))return v(e,r,o,c);if(ie(o)){if(l=ie(o),typeof l!=`function`)throw Error(a(150));return o=l.call(o),y(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,Ca(o),c);if(o.$$typeof===x)return b(e,r,Gi(e,o),c);Ta(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=i(r,o),c.return=e,e=c):(n(e,r),c=ni(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{Sa=0;var i=b(e,t,n,r);return xa=null,i}catch(t){if(t===da||t===pa)throw t;var a=Xr(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var Da=Ea(!0),Oa=Ea(!1),ka=!1;function Aa(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function ja(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Ma(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Na(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,Nl&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=qr(e),Kr(e,null,n),t}return Ur(e,r,t,n),qr(e)}function Pa(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,it(e,n)}}function Fa(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var Ia=!1;function La(){if(Ia){var e=na;if(e!==null)throw e}}function Ra(e,t,n,r){Ia=!1;var i=e.updateQueue;ka=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,m=f!==s.lane;if(m?(Fl&f)===f:(r&f)===f){f!==0&&f===ta&&(Ia=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var h=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(h=g.payload,typeof h==`function`){d=h.call(_,d,f);break a}d=h;break a;case 3:h.flags=h.flags&-65537|128;case 0:if(h=g.payload,f=typeof h==`function`?h.call(_,d,f):h,f==null)break a;d=p({},d,f);break a;case 2:ka=!0}}f=s.callback,f!==null&&(e.flags|=64,m&&(e.flags|=8192),m=i.callbacks,m===null?i.callbacks=[f]:m.push(f))}else m={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=m,c=d):u=u.next=m,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;m=s,s=m.next,m.next=null,i.lastBaseUpdate=m,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Ul|=o,e.lanes=o,e.memoizedState=d}}function za(e,t){if(typeof e!=`function`)throw Error(a(191,e));e.call(t)}function Ba(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=D.T,s={};D.T=s,Es(e,!1,t,n);try{var c=i(),l=D.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?Ts(e,t,aa(c,r),du(e)):Ts(e,t,r,du(e))}catch(n){Ts(e,t,{then:function(){},status:`rejected`,reason:n},du())}finally{O.p=a,o!==null&&s.types!==null&&(o.types=s.types),D.T=o}}function hs(){}function gs(e,t,n,r){if(e.tag!==5)throw Error(a(476));var i=_s(e).queue;ms(e,i,t,k,n===null?hs:function(){return vs(e),n(r)})}function _s(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:k,baseState:k,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Do,lastRenderedState:k},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Do,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function vs(e){var t=_s(e);t.next===null&&(t=e.alternate.memoizedState),Ts(e,t.next.queue,{},du())}function ys(){return Wi(Qf)}function bs(){return So().memoizedState}function xs(){return So().memoizedState}function Ss(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=du();e=Ma(n);var r=Na(t,e,n);r!==null&&(pu(r,t,n),Pa(r,t,n)),t={cache:Zi()},e.payload=t;return}t=t.return}}function Cs(e,t,n){var r=du();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},Ds(e)?Os(t,n):(n=Wr(e,t,n,r),n!==null&&(pu(n,e,r),ks(n,t,r)))}function ws(e,t,n){Ts(e,t,n,du())}function Ts(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(Ds(e))Os(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,ur(s,o))return Ur(e,t,i,0),Pl===null&&Hr(),!1}catch{}if(n=Wr(e,t,i,r),n!==null)return pu(n,e,r),ks(n,t,r),!0}return!1}function Es(e,t,n,r){if(r={lane:2,revertLane:ud(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},Ds(e)){if(t)throw Error(a(479))}else t=Wr(e,n,r,2),t!==null&&pu(t,e,2)}function Ds(e){var t=e.alternate;return e===Z||t!==null&&t===Z}function Os(e,t){ao=io=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function ks(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,it(e,n)}}var As={readContext:Wi,use:To,useCallback:fo,useContext:fo,useEffect:fo,useImperativeHandle:fo,useLayoutEffect:fo,useInsertionEffect:fo,useMemo:fo,useReducer:fo,useRef:fo,useState:fo,useDebugValue:fo,useDeferredValue:fo,useTransition:fo,useSyncExternalStore:fo,useId:fo,useHostTransitionStatus:fo,useFormState:fo,useActionState:fo,useOptimistic:fo,useMemoCache:fo,useCacheRefresh:fo};As.useEffectEvent=fo;var js={readContext:Wi,use:To,useCallback:function(e,t){return xo().memoizedState=[e,t===void 0?null:t],e},useContext:Wi,useEffect:ts,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),$o(4194308,4,ss.bind(null,t,e),n)},useLayoutEffect:function(e,t){return $o(4194308,4,e,t)},useInsertionEffect:function(e,t){$o(4,2,e,t)},useMemo:function(e,t){var n=xo();t=t===void 0?null:t;var r=e();if(oo){Ve(!0);try{e()}finally{Ve(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=xo();if(n!==void 0){var i=n(t);if(oo){Ve(!0);try{n(t)}finally{Ve(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=Cs.bind(null,Z,e),[r.memoizedState,e]},useRef:function(e){var t=xo();return e={current:e},t.memoizedState=e},useState:function(e){e=Lo(e);var t=e.queue,n=ws.bind(null,Z,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:ls,useDeferredValue:function(e,t){return fs(xo(),e,t)},useTransition:function(){var e=Lo(!1);return e=ms.bind(null,Z,e.queue,!0,!1),xo().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=Z,i=xo();if(Ci){if(n===void 0)throw Error(a(407));n=n()}else{if(n=t(),Pl===null)throw Error(a(349));Fl&127||Mo(r,t,n)}i.memoizedState=n;var o={value:n,getSnapshot:t};return i.queue=o,ts(Po.bind(null,r,o,e),[e]),r.flags|=2048,Zo(9,{destroy:void 0},No.bind(null,r,o,n,t),null),n},useId:function(){var e=xo(),t=Pl.identifierPrefix;if(Ci){var n=hi,r=mi;n=(r&~(1<<32-He(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=so++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(i,{is:r.is}):s.createElement(i)}}o[ut]=t,o[dt]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Pd(o,i,r),i){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&Tc(t)}}return Ac(t),Ec(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Tc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(a(166));if(e=N.current,Ai(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,i=xi,i!==null)switch(i.tag){case 27:case 5:r=i.memoizedProps}e[ut]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||jd(e.nodeValue,n)),e||Di(t,!0)}else e=Bd(e).createTextNode(r),e[ut]=t,t.stateNode=e}return Ac(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Ai(t),n!==null){if(e===null){if(!r)throw Error(a(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(a(557));e[ut]=t}else ji(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Ac(t),e=!1}else n=Mi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(Qa(t),t):(Qa(t),null);if(t.flags&128)throw Error(a(558))}return Ac(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(i=Ai(t),r!==null&&r.dehydrated!==null){if(e===null){if(!i)throw Error(a(318));if(i=t.memoizedState,i=i===null?null:i.dehydrated,!i)throw Error(a(317));i[ut]=t}else ji(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Ac(t),i=!1}else i=Mi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=i),i=!0;if(!i)return t.flags&256?(Qa(t),t):(Qa(t),null)}return Qa(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,i=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(i=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==i&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),Oc(t,t.updateQueue),Ac(t),null);case 4:return he(),e===null&&xd(t.stateNode.containerInfo),Ac(t),null;case 10:return Ri(t.type),Ac(t),null;case 19:if(j($a),r=t.memoizedState,r===null)return Ac(t),null;if(i=(t.flags&128)!=0,o=r.rendering,o===null)if(i)kc(r,!1);else{if(Hl!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=eo(e),o!==null){for(t.flags|=128,kc(r,!1),e=o.updateQueue,t.updateQueue=e,Oc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)$r(n,e),n=n.sibling;return M($a,$a.current&1|2),Ci&&gi(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&Ae()>$l&&(t.flags|=128,i=!0,kc(r,!1),t.lanes=4194304)}else{if(!i)if(e=eo(o),e!==null){if(t.flags|=128,i=!0,e=e.updateQueue,t.updateQueue=e,Oc(t,e),kc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!Ci)return Ac(t),null}else 2*Ae()-r.renderingStartTime>$l&&n!==536870912&&(t.flags|=128,i=!0,kc(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(Ac(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=Ae(),e.sibling=null,n=$a.current,M($a,i?n&1|2:n&1),Ci&&gi(t,r.treeForkCount),e);case 22:case 23:return Qa(t),Ga(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(Ac(t),t.subtreeFlags&6&&(t.flags|=8192)):Ac(t),n=t.updateQueue,n!==null&&Oc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&j(sa),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),Ri(Xi),Ac(t),null;case 25:return null;case 30:return null}throw Error(a(156,t.tag))}function Mc(e,t){switch(yi(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Ri(Xi),he(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return _e(t),null;case 31:if(t.memoizedState!==null){if(Qa(t),t.alternate===null)throw Error(a(340));ji()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(Qa(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(a(340));ji()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return j($a),null;case 4:return he(),null;case 10:return Ri(t.type),null;case 22:case 23:return Qa(t),Ga(),e!==null&&j(sa),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Ri(Xi),null;case 25:return null;default:return null}}function Nc(e,t){switch(yi(t),t.tag){case 3:Ri(Xi),he();break;case 26:case 27:case 5:_e(t);break;case 4:he();break;case 31:t.memoizedState!==null&&Qa(t);break;case 13:Qa(t);break;case 19:j($a);break;case 10:Ri(t.type);break;case 22:case 23:Qa(t),Ga(),e!==null&&j(sa);break;case 24:Ri(Xi)}}function Pc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Uu(t,t.return,e)}}function Fc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Uu(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Uu(t,t.return,e)}}function Ic(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{Ba(t,n)}catch(t){Uu(e,e.return,t)}}}function Lc(e,t,n){n.props=Rs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Uu(e,t,n)}}function Rc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Uu(e,t,n)}}function zc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){Uu(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Uu(e,t,n)}else n.current=null}function Bc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Uu(e,e.return,t)}}function Vc(e,t,n){try{var r=e.stateNode;Fd(r,e.type,n,t),r[dt]=t}catch(t){Uu(e,e.return,t)}}function Hc(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Zd(e.type)||e.tag===4}function Uc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Hc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Zd(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Wc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Yt));else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(Wc(e,t,n),e=e.sibling;e!==null;)Wc(e,t,n),e=e.sibling}function Gc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(Gc(e,t,n),e=e.sibling;e!==null;)Gc(e,t,n),e=e.sibling}function Kc(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Pd(t,r,n),t[ut]=e,t[dt]=n}catch(t){Uu(e,e.return,t)}}var qc=!1,Jc=!1,Yc=!1,Xc=typeof WeakSet==`function`?WeakSet:Set,Zc=null;function Qc(e,t){if(e=e.containerInfo,Rd=sp,e=hr(e),gr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var i=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||i!==0&&f.nodeType!==3||(c=s+i),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===i&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(zd={focusedElem:e,selectionRange:n},sp=!1,Zc=t;Zc!==null;)if(t=Zc,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,Zc=e;else for(;Zc!==null;){switch(t=Zc,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Pd(o,r,n),o[ut]=e,St(o),r=o;break a;case`link`:var s=Vf(`link`,`href`,i).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=pr(s,h),v=pr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,D.T=null,n=su,su=null;var o=ru,s=au;if(nu=0,iu=ru=null,au=0,Nl&6)throw Error(a(331));var c=Nl;if(Nl|=4,Ol(o.current),bl(o,o.current,s,n),Nl=c,rd(0,!1),Be&&typeof Be.onPostCommitFiberRoot==`function`)try{Be.onPostCommitFiberRoot(ze,o)}catch{}return!0}finally{O.p=i,D.T=r,zu(e,t)}}function Hu(e,t,n){t=oi(n,t),t=Ws(e.stateNode,t,2),e=Na(e,t,2),e!==null&&(tt(e,2),nd(e))}function Uu(e,t,n){if(e.tag===3)Hu(e,e,n);else for(;t!==null;){if(t.tag===3){Hu(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(tu===null||!tu.has(r))){e=oi(n,e),n=Gs(2),r=Na(t,n,2),r!==null&&(Ks(n,r,t,e),tt(r,2),nd(r));break}}t=t.return}}function Wu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Ml;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Bl=!0,i.add(n),e=Gu.bind(null,e,t,n),t.then(e,e))}function Gu(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,Pl===e&&(Fl&n)===n&&(Hl===4||Hl===3&&(Fl&62914560)===Fl&&300>Ae()-Zl?!(Nl&2)&&bu(e,0):Gl|=n,ql===Fl&&(ql=0)),nd(e)}function Ku(e,t){t===0&&(t=$e()),e=Gr(e,t),e!==null&&(tt(e,t),nd(e))}function qu(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Ku(e,n)}function Ju(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,i=e.memoizedState;i!==null&&(n=i.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(a(314))}r!==null&&r.delete(t),Ku(e,n)}function Yu(e,t){return Ee(e,t)}var Xu=null,Zu=null,Qu=!1,$u=!1,ed=!1,td=0;function nd(e){e!==Zu&&e.next===null&&(Zu===null?Xu=Zu=e:Zu=Zu.next=e),$u=!0,Qu||(Qu=!0,ld())}function rd(e,t){if(!ed&&$u){ed=!0;do for(var n=!1,r=Xu;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-He(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,cd(r,a))}else a=Fl,a=Xe(r,r===Pl?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||Ze(r,a)||(n=!0,cd(r,a));r=r.next}while(n);ed=!1}}function id(){ad()}function ad(){$u=Qu=!1;var e=0;td!==0&&Gd()&&(e=td);for(var t=Ae(),n=null,r=Xu;r!==null;){var i=r.next,a=od(r,t);a===0?(r.next=null,n===null?Xu=i:n.next=i,i===null&&(Zu=n)):(n=r,(e!==0||a&3)&&($u=!0)),r=i}nu!==0&&nu!==5||rd(e,!1),td!==0&&(td=0)}function od(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Id(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function xf(e,t,n){var r=bf;if(r&&typeof t==`string`&&t){var i=Bt(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),hf.has(i)||(hf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Pd(t,`link`,e),St(t),r.head.appendChild(t)))}}function Sf(e){_f.D(e),xf(`dns-prefetch`,e,null)}function Cf(e,t){_f.C(e,t),xf(`preconnect`,e,t)}function wf(e,t,n){_f.L(e,t,n);var r=bf;if(r&&e&&t){var i=`link[rel="preload"][as="`+Bt(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+Bt(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+Bt(n.imageSizes)+`"]`)):i+=`[href="`+Bt(e)+`"]`;var a=i;switch(t){case`style`:a=Af(e);break;case`script`:a=Pf(e)}mf.has(a)||(e=p({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),mf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(jf(a))||t===`script`&&r.querySelector(Ff(a))||(t=r.createElement(`link`),Pd(t,`link`,e),St(t),r.head.appendChild(t)))}}function Tf(e,t){_f.m(e,t);var n=bf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+Bt(r)+`"][href="`+Bt(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Pf(e)}if(!mf.has(a)&&(e=p({rel:`modulepreload`,href:e},t),mf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Ff(a)))return}r=n.createElement(`link`),Pd(r,`link`,e),St(r),n.head.appendChild(r)}}}function Ef(e,t,n){_f.S(e,t,n);var r=bf;if(r&&e){var i=xt(r).hoistableStyles,a=Af(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(jf(a)))s.loading=5;else{e=p({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=mf.get(a))&&Rf(e,n);var c=o=r.createElement(`link`);St(c),Pd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Lf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Df(e,t){_f.X(e,t);var n=bf;if(n&&e){var r=xt(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=p({src:e,async:!0},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),St(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Of(e,t){_f.M(e,t);var n=bf;if(n&&e){var r=xt(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=p({src:e,async:!0,type:`module`},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),St(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function kf(e,t,n,r){var i=(i=N.current)?gf(i):null;if(!i)throw Error(a(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Af(n.href),n=xt(i).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Af(n.href);var o=xt(i).hoistableStyles,s=o.get(e);if(s||(i=i.ownerDocument||i,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=i.querySelector(jf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),mf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},mf.set(e,n),o||Nf(i,e,n,s.state))),t&&r===null)throw Error(a(528,``));return s}if(t&&r!==null)throw Error(a(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Pf(n),n=xt(i).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(a(444,e))}}function Af(e){return`href="`+Bt(e)+`"`}function jf(e){return`link[rel="stylesheet"][`+e+`]`}function Mf(e){return p({},e,{"data-precedence":e.precedence,precedence:null})}function Nf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Pd(t,`link`,n),St(t),e.head.appendChild(t))}function Pf(e){return`[src="`+Bt(e)+`"]`}function Ff(e){return`script[async]`+e}function If(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+Bt(n.href)+`"]`);if(r)return t.instance=r,St(r),r;var i=p({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),St(r),Pd(r,`style`,i),Lf(r,n.precedence,e),t.instance=r;case`stylesheet`:i=Af(n.href);var o=e.querySelector(jf(i));if(o)return t.state.loading|=4,t.instance=o,St(o),o;r=Mf(n),(i=mf.get(i))&&Rf(r,i),o=(e.ownerDocument||e).createElement(`link`),St(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Pd(o,`link`,r),t.state.loading|=4,Lf(o,n.precedence,e),t.instance=o;case`script`:return o=Pf(n.src),(i=e.querySelector(Ff(o)))?(t.instance=i,St(i),i):(r=n,(i=mf.get(o))&&(r=p({},n),zf(r,i)),e=e.ownerDocument||e,i=e.createElement(`script`),St(i),Pd(i,`link`,r),e.head.appendChild(i),t.instance=i);case`void`:return null;default:throw Error(a(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Lf(r,n.precedence,e));return t.instance}function Lf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Uf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Wf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Gf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Af(r.href),a=t.querySelector(jf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=Jf.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,St(a);return}a=t.ownerDocument||t,r=Mf(r),(i=mf.get(i))&&Rf(r,i),a=a.createElement(`link`),St(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Pd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Jf.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Kf=0;function qf(e,t){return e.stylesheets&&e.count===0&&Xf(e,e.stylesheets),0Kf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function Jf(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Xf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Yf=null;function Xf(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Yf=new Map,t.forEach(Zf,e),Yf=null,Jf.call(e))}function Zf(e,t){if(!(t.state.loading&4)){var n=Yf.get(e);if(n)var r=n.get(null);else{n=new Map,Yf.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=A()})),M=e((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),de=e(((e,t)=>{t.exports=M()})),fe=j(),N=de();function pe({children:e,hidden:t}){return t?null:(0,N.jsx)(`div`,{className:`px-4 py-7 text-center text-sm text-muted-foreground`,children:e})}function me(e){var t,n,r=``;if(typeof e==`string`||typeof e==`number`)r+=e;else if(typeof e==`object`)if(Array.isArray(e)){var i=e.length;for(t=0;t{let n=Array(e.length+t.length);for(let t=0;t({classGroupId:e,validator:t}),ve=(e=new Map,t=null,n)=>({nextPart:e,validators:t,classGroupId:n}),ye=`-`,be=[],xe=`arbitrary..`,Se=e=>{let t=Te(e),{conflictingClassGroups:n,conflictingClassGroupModifiers:r}=e;return{getClassGroupId:e=>{if(e.startsWith(`[`)&&e.endsWith(`]`))return we(e);let n=e.split(ye);return Ce(n,+(n[0]===``&&n.length>1),t)},getConflictingClassGroupIds:(e,t)=>{if(t){let t=r[e],i=n[e];return t?i?ge(i,t):t:i||be}return n[e]||be}}},Ce=(e,t,n)=>{if(e.length-t===0)return n.classGroupId;let r=e[t],i=n.nextPart.get(r);if(i){let n=Ce(e,t+1,i);if(n)return n}let a=n.validators;if(a===null)return;let o=t===0?e.join(ye):e.slice(t).join(ye),s=a.length;for(let e=0;ee.slice(1,-1).indexOf(`:`)===-1?void 0:(()=>{let t=e.slice(1,-1),n=t.indexOf(`:`),r=t.slice(0,n);return r?xe+r:void 0})(),Te=e=>{let{theme:t,classGroups:n}=e;return Ee(n,t)},Ee=(e,t)=>{let n=ve();for(let r in e){let i=e[r];De(i,n,r,t)}return n},De=(e,t,n,r)=>{let i=e.length;for(let a=0;a{if(typeof e==`string`){ke(e,t,n);return}if(typeof e==`function`){Ae(e,t,n,r);return}je(e,t,n,r)},ke=(e,t,n)=>{let r=e===``?t:Me(t,e);r.classGroupId=n},Ae=(e,t,n,r)=>{if(Ne(e)){De(e(r),t,n,r);return}t.validators===null&&(t.validators=[]),t.validators.push(_e(n,e))},je=(e,t,n,r)=>{let i=Object.entries(e),a=i.length;for(let e=0;e{let n=e,r=t.split(ye),i=r.length;for(let e=0;e`isThemeGetter`in e&&e.isThemeGetter===!0,Pe=e=>{if(e<1)return{get:()=>void 0,set:()=>{}};let t=0,n=Object.create(null),r=Object.create(null),i=(i,a)=>{n[i]=a,t++,t>e&&(t=0,r=n,n=Object.create(null))};return{get(e){let t=n[e];if(t!==void 0)return t;if((t=r[e])!==void 0)return i(e,t),t},set(e,t){e in n?n[e]=t:i(e,t)}}},Fe=`!`,Ie=`:`,Le=[],Re=(e,t,n,r,i)=>({modifiers:e,hasImportantModifier:t,baseClassName:n,maybePostfixModifierPosition:r,isExternal:i}),ze=e=>{let{prefix:t,experimentalParseClassName:n}=e,r=e=>{let t=[],n=0,r=0,i=0,a,o=e.length;for(let s=0;si?a-i:void 0;return Re(t,l,c,u)};if(t){let e=t+Ie,n=r;r=t=>t.startsWith(e)?n(t.slice(e.length)):Re(Le,!1,t,void 0,!0)}if(n){let e=r;r=t=>n({className:t,parseClassName:e})}return r},Be=e=>{let t=new Map;return e.orderSensitiveModifiers.forEach((e,n)=>{t.set(e,1e6+n)}),e=>{let n=[],r=[];for(let i=0;i0&&(r.sort(),n.push(...r),r=[]),n.push(a)):r.push(a)}return r.length>0&&(r.sort(),n.push(...r)),n}},Ve=e=>({cache:Pe(e.cacheSize),parseClassName:ze(e),sortModifiers:Be(e),postfixLookupClassGroupIds:He(e),...Se(e)}),He=e=>{let t=Object.create(null),n=e.postfixLookupClassGroups;if(n)for(let e=0;e{let{parseClassName:n,getClassGroupId:r,getConflictingClassGroupIds:i,sortModifiers:a,postfixLookupClassGroupIds:o}=t,s=[],c=e.trim().split(Ue),l=``;for(let e=c.length-1;e>=0;--e){let t=c[e],{isExternal:u,modifiers:d,hasImportantModifier:f,baseClassName:p,maybePostfixModifierPosition:m}=n(t);if(u){l=t+(l.length>0?` `+l:l);continue}let h=!!m,g;if(h){g=r(p.substring(0,m));let e=g&&o[g]?r(p):void 0;e&&e!==g&&(g=e,h=!1)}else g=r(p);if(!g){if(!h){l=t+(l.length>0?` `+l:l);continue}if(g=r(p),!g){l=t+(l.length>0?` `+l:l);continue}h=!1}let _=d.length===0?``:d.length===1?d[0]:a(d).join(`:`),v=f?_+Fe:_,y=v+g;if(s.indexOf(y)>-1)continue;s.push(y);let b=i(g,h);for(let e=0;e0?` `+l:l)}return l},Ge=(...e)=>{let t=0,n,r,i=``;for(;t{if(typeof e==`string`)return e;let t,n=``;for(let r=0;r{let n,r,i,a,o=o=>(n=Ve(t.reduce((e,t)=>t(e),e())),r=n.cache.get,i=n.cache.set,a=s,s(o)),s=e=>{let t=r(e);if(t)return t;let a=We(e,n);return i(e,a),a};return a=o,(...e)=>a(Ge(...e))},Je=[],Ye=e=>{let t=t=>t[e]||Je;return t.isThemeGetter=!0,t},Xe=/^\[(?:(\w[\w-]*):)?(.+)\]$/i,Ze=/^\((?:(\w[\w-]*):)?(.+)\)$/i,Qe=/^\d+(?:\.\d+)?\/\d+(?:\.\d+)?$/,$e=/^(\d+(\.\d+)?)?(xs|sm|md|lg|xl)$/,et=/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh|cq(w|h|i|b|min|max))|\b(calc|min|max|clamp)\(.+\)|^0$/,tt=/^(rgba?|hsla?|hwb|(ok)?(lab|lch)|color-mix)\(.+\)$/,nt=/^(inset_)?-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/,rt=/^(url|image|image-set|cross-fade|element|(repeating-)?(linear|radial|conic)-gradient)\(.+\)$/,it=e=>Qe.test(e),P=e=>!!e&&!Number.isNaN(Number(e)),at=e=>!!e&&Number.isInteger(Number(e)),ot=e=>e.endsWith(`%`)&&P(e.slice(0,-1)),st=e=>$e.test(e),ct=()=>!0,lt=e=>et.test(e)&&!tt.test(e),ut=()=>!1,dt=e=>nt.test(e),ft=e=>rt.test(e),pt=e=>!F(e)&&!I(e),mt=e=>e.startsWith(`@container`)&&(e[10]===`/`&&e[11]!==void 0||e[11]===`s`&&e[16]!==void 0&&e.startsWith(`-size/`,10)||e[11]===`n`&&e[18]!==void 0&&e.startsWith(`-normal/`,10)),ht=e=>At(e,Pt,ut),F=e=>Xe.test(e),gt=e=>At(e,Ft,lt),_t=e=>At(e,It,P),vt=e=>At(e,Rt,ct),yt=e=>At(e,Lt,ut),bt=e=>At(e,Mt,ut),xt=e=>At(e,Nt,ft),St=e=>At(e,zt,dt),I=e=>Ze.test(e),Ct=e=>jt(e,Ft),wt=e=>jt(e,Lt),Tt=e=>jt(e,Mt),Et=e=>jt(e,Pt),Dt=e=>jt(e,Nt),Ot=e=>jt(e,zt,!0),kt=e=>jt(e,Rt,!0),At=(e,t,n)=>{let r=Xe.exec(e);return r?r[1]?t(r[1]):n(r[2]):!1},jt=(e,t,n=!1)=>{let r=Ze.exec(e);return r?r[1]?t(r[1]):n:!1},Mt=e=>e===`position`||e===`percentage`,Nt=e=>e===`image`||e===`url`,Pt=e=>e===`length`||e===`size`||e===`bg-size`,Ft=e=>e===`length`,It=e=>e===`number`,Lt=e=>e===`family-name`,Rt=e=>e===`number`||e===`weight`,zt=e=>e===`shadow`,Bt=qe(()=>{let e=Ye(`color`),t=Ye(`font`),n=Ye(`text`),r=Ye(`font-weight`),i=Ye(`tracking`),a=Ye(`leading`),o=Ye(`breakpoint`),s=Ye(`container`),c=Ye(`spacing`),l=Ye(`radius`),u=Ye(`shadow`),d=Ye(`inset-shadow`),f=Ye(`text-shadow`),p=Ye(`drop-shadow`),m=Ye(`blur`),h=Ye(`perspective`),g=Ye(`aspect`),_=Ye(`ease`),v=Ye(`animate`),y=()=>[`auto`,`avoid`,`all`,`avoid-page`,`page`,`left`,`right`,`column`],b=()=>[`center`,`top`,`bottom`,`left`,`right`,`top-left`,`left-top`,`top-right`,`right-top`,`bottom-right`,`right-bottom`,`bottom-left`,`left-bottom`],x=()=>[...b(),I,F],S=()=>[`auto`,`hidden`,`clip`,`visible`,`scroll`],ee=()=>[`auto`,`contain`,`none`],C=()=>[I,F,c],w=()=>[it,`full`,`auto`,...C()],te=()=>[at,`none`,`subgrid`,I,F],ne=()=>[`auto`,{span:[`full`,at,I,F]},at,I,F],T=()=>[at,`auto`,I,F],re=()=>[`auto`,`min`,`max`,`fr`,I,F],ie=()=>[`start`,`end`,`center`,`between`,`around`,`evenly`,`stretch`,`baseline`,`center-safe`,`end-safe`],ae=()=>[`start`,`end`,`center`,`stretch`,`center-safe`,`end-safe`],oe=()=>[`auto`,...C()],E=()=>[it,`auto`,`full`,`dvw`,`dvh`,`lvw`,`lvh`,`svw`,`svh`,`min`,`max`,`fit`,...C()],D=()=>[it,`screen`,`full`,`dvw`,`lvw`,`svw`,`min`,`max`,`fit`,...C()],O=()=>[it,`screen`,`full`,`lh`,`dvh`,`lvh`,`svh`,`min`,`max`,`fit`,...C()],k=()=>[e,I,F],se=()=>[...b(),Tt,bt,{position:[I,F]}],ce=()=>[`no-repeat`,{repeat:[``,`x`,`y`,`space`,`round`]}],le=()=>[`auto`,`cover`,`contain`,Et,ht,{size:[I,F]}],ue=()=>[ot,Ct,gt],A=()=>[``,`none`,`full`,l,I,F],j=()=>[``,P,Ct,gt],M=()=>[`solid`,`dashed`,`dotted`,`double`],de=()=>[`normal`,`multiply`,`screen`,`overlay`,`darken`,`lighten`,`color-dodge`,`color-burn`,`hard-light`,`soft-light`,`difference`,`exclusion`,`hue`,`saturation`,`color`,`luminosity`],fe=()=>[P,ot,Tt,bt],N=()=>[``,`none`,m,I,F],pe=()=>[`none`,P,I,F],me=()=>[`none`,P,I,F],he=()=>[P,I,F],ge=()=>[it,`full`,...C()];return{cacheSize:500,theme:{animate:[`spin`,`ping`,`pulse`,`bounce`],aspect:[`video`],blur:[st],breakpoint:[st],color:[ct],container:[st],"drop-shadow":[st],ease:[`in`,`out`,`in-out`],font:[pt],"font-weight":[`thin`,`extralight`,`light`,`normal`,`medium`,`semibold`,`bold`,`extrabold`,`black`],"inset-shadow":[st],leading:[`none`,`tight`,`snug`,`normal`,`relaxed`,`loose`],perspective:[`dramatic`,`near`,`normal`,`midrange`,`distant`,`none`],radius:[st],shadow:[st],spacing:[`px`,P],text:[st],"text-shadow":[st],tracking:[`tighter`,`tight`,`normal`,`wide`,`wider`,`widest`]},classGroups:{aspect:[{aspect:[`auto`,`square`,it,F,I,g]}],container:[`container`],"container-type":[{"@container":[``,`normal`,`size`,I,F]}],"container-named":[mt],columns:[{columns:[P,F,I,s]}],"break-after":[{"break-after":y()}],"break-before":[{"break-before":y()}],"break-inside":[{"break-inside":[`auto`,`avoid`,`avoid-page`,`avoid-column`]}],"box-decoration":[{"box-decoration":[`slice`,`clone`]}],box:[{box:[`border`,`content`]}],display:[`block`,`inline-block`,`inline`,`flex`,`inline-flex`,`table`,`inline-table`,`table-caption`,`table-cell`,`table-column`,`table-column-group`,`table-footer-group`,`table-header-group`,`table-row-group`,`table-row`,`flow-root`,`grid`,`inline-grid`,`contents`,`list-item`,`hidden`],sr:[`sr-only`,`not-sr-only`],float:[{float:[`right`,`left`,`none`,`start`,`end`]}],clear:[{clear:[`left`,`right`,`both`,`none`,`start`,`end`]}],isolation:[`isolate`,`isolation-auto`],"object-fit":[{object:[`contain`,`cover`,`fill`,`none`,`scale-down`]}],"object-position":[{object:x()}],overflow:[{overflow:S()}],"overflow-x":[{"overflow-x":S()}],"overflow-y":[{"overflow-y":S()}],overscroll:[{overscroll:ee()}],"overscroll-x":[{"overscroll-x":ee()}],"overscroll-y":[{"overscroll-y":ee()}],position:[`static`,`fixed`,`absolute`,`relative`,`sticky`],inset:[{inset:w()}],"inset-x":[{"inset-x":w()}],"inset-y":[{"inset-y":w()}],start:[{"inset-s":w(),start:w()}],end:[{"inset-e":w(),end:w()}],"inset-bs":[{"inset-bs":w()}],"inset-be":[{"inset-be":w()}],top:[{top:w()}],right:[{right:w()}],bottom:[{bottom:w()}],left:[{left:w()}],visibility:[`visible`,`invisible`,`collapse`],z:[{z:[at,`auto`,I,F]}],basis:[{basis:[it,`full`,`auto`,s,...C()]}],"flex-direction":[{flex:[`row`,`row-reverse`,`col`,`col-reverse`]}],"flex-wrap":[{flex:[`nowrap`,`wrap`,`wrap-reverse`]}],flex:[{flex:[P,it,`auto`,`initial`,`none`,F]}],grow:[{grow:[``,P,I,F]}],shrink:[{shrink:[``,P,I,F]}],order:[{order:[at,`first`,`last`,`none`,I,F]}],"grid-cols":[{"grid-cols":te()}],"col-start-end":[{col:ne()}],"col-start":[{"col-start":T()}],"col-end":[{"col-end":T()}],"grid-rows":[{"grid-rows":te()}],"row-start-end":[{row:ne()}],"row-start":[{"row-start":T()}],"row-end":[{"row-end":T()}],"grid-flow":[{"grid-flow":[`row`,`col`,`dense`,`row-dense`,`col-dense`]}],"auto-cols":[{"auto-cols":re()}],"auto-rows":[{"auto-rows":re()}],gap:[{gap:C()}],"gap-x":[{"gap-x":C()}],"gap-y":[{"gap-y":C()}],"justify-content":[{justify:[...ie(),`normal`]}],"justify-items":[{"justify-items":[...ae(),`normal`]}],"justify-self":[{"justify-self":[`auto`,...ae()]}],"align-content":[{content:[`normal`,...ie()]}],"align-items":[{items:[...ae(),{baseline:[``,`last`]}]}],"align-self":[{self:[`auto`,...ae(),{baseline:[``,`last`]}]}],"place-content":[{"place-content":ie()}],"place-items":[{"place-items":[...ae(),`baseline`]}],"place-self":[{"place-self":[`auto`,...ae()]}],p:[{p:C()}],px:[{px:C()}],py:[{py:C()}],ps:[{ps:C()}],pe:[{pe:C()}],pbs:[{pbs:C()}],pbe:[{pbe:C()}],pt:[{pt:C()}],pr:[{pr:C()}],pb:[{pb:C()}],pl:[{pl:C()}],m:[{m:oe()}],mx:[{mx:oe()}],my:[{my:oe()}],ms:[{ms:oe()}],me:[{me:oe()}],mbs:[{mbs:oe()}],mbe:[{mbe:oe()}],mt:[{mt:oe()}],mr:[{mr:oe()}],mb:[{mb:oe()}],ml:[{ml:oe()}],"space-x":[{"space-x":C()}],"space-x-reverse":[`space-x-reverse`],"space-y":[{"space-y":C()}],"space-y-reverse":[`space-y-reverse`],size:[{size:E()}],"inline-size":[{inline:[`auto`,...D()]}],"min-inline-size":[{"min-inline":[`auto`,...D()]}],"max-inline-size":[{"max-inline":[`none`,...D()]}],"block-size":[{block:[`auto`,...O()]}],"min-block-size":[{"min-block":[`auto`,...O()]}],"max-block-size":[{"max-block":[`none`,...O()]}],w:[{w:[s,`screen`,...E()]}],"min-w":[{"min-w":[s,`screen`,`none`,...E()]}],"max-w":[{"max-w":[s,`screen`,`none`,`prose`,{screen:[o]},...E()]}],h:[{h:[`screen`,`lh`,...E()]}],"min-h":[{"min-h":[`screen`,`lh`,`none`,...E()]}],"max-h":[{"max-h":[`screen`,`lh`,...E()]}],"font-size":[{text:[`base`,n,Ct,gt]}],"font-smoothing":[`antialiased`,`subpixel-antialiased`],"font-style":[`italic`,`not-italic`],"font-weight":[{font:[r,kt,vt]}],"font-stretch":[{"font-stretch":[`ultra-condensed`,`extra-condensed`,`condensed`,`semi-condensed`,`normal`,`semi-expanded`,`expanded`,`extra-expanded`,`ultra-expanded`,ot,F]}],"font-family":[{font:[wt,yt,t]}],"font-features":[{"font-features":[F]}],"fvn-normal":[`normal-nums`],"fvn-ordinal":[`ordinal`],"fvn-slashed-zero":[`slashed-zero`],"fvn-figure":[`lining-nums`,`oldstyle-nums`],"fvn-spacing":[`proportional-nums`,`tabular-nums`],"fvn-fraction":[`diagonal-fractions`,`stacked-fractions`],tracking:[{tracking:[i,I,F]}],"line-clamp":[{"line-clamp":[P,`none`,I,_t]}],leading:[{leading:[a,...C()]}],"list-image":[{"list-image":[`none`,I,F]}],"list-style-position":[{list:[`inside`,`outside`]}],"list-style-type":[{list:[`disc`,`decimal`,`none`,I,F]}],"text-alignment":[{text:[`left`,`center`,`right`,`justify`,`start`,`end`]}],"placeholder-color":[{placeholder:k()}],"text-color":[{text:k()}],"text-decoration":[`underline`,`overline`,`line-through`,`no-underline`],"text-decoration-style":[{decoration:[...M(),`wavy`]}],"text-decoration-thickness":[{decoration:[P,`from-font`,`auto`,I,gt]}],"text-decoration-color":[{decoration:k()}],"underline-offset":[{"underline-offset":[P,`auto`,I,F]}],"text-transform":[`uppercase`,`lowercase`,`capitalize`,`normal-case`],"text-overflow":[`truncate`,`text-ellipsis`,`text-clip`],"text-wrap":[{text:[`wrap`,`nowrap`,`balance`,`pretty`]}],indent:[{indent:C()}],"tab-size":[{tab:[at,I,F]}],"vertical-align":[{align:[`baseline`,`top`,`middle`,`bottom`,`text-top`,`text-bottom`,`sub`,`super`,I,F]}],whitespace:[{whitespace:[`normal`,`nowrap`,`pre`,`pre-line`,`pre-wrap`,`break-spaces`]}],break:[{break:[`normal`,`words`,`all`,`keep`]}],wrap:[{wrap:[`break-word`,`anywhere`,`normal`]}],hyphens:[{hyphens:[`none`,`manual`,`auto`]}],content:[{content:[`none`,I,F]}],"bg-attachment":[{bg:[`fixed`,`local`,`scroll`]}],"bg-clip":[{"bg-clip":[`border`,`padding`,`content`,`text`]}],"bg-origin":[{"bg-origin":[`border`,`padding`,`content`]}],"bg-position":[{bg:se()}],"bg-repeat":[{bg:ce()}],"bg-size":[{bg:le()}],"bg-image":[{bg:[`none`,{linear:[{to:[`t`,`tr`,`r`,`br`,`b`,`bl`,`l`,`tl`]},at,I,F],radial:[``,I,F],conic:[at,I,F]},Dt,xt]}],"bg-color":[{bg:k()}],"gradient-from-pos":[{from:ue()}],"gradient-via-pos":[{via:ue()}],"gradient-to-pos":[{to:ue()}],"gradient-from":[{from:k()}],"gradient-via":[{via:k()}],"gradient-to":[{to:k()}],rounded:[{rounded:A()}],"rounded-s":[{"rounded-s":A()}],"rounded-e":[{"rounded-e":A()}],"rounded-t":[{"rounded-t":A()}],"rounded-r":[{"rounded-r":A()}],"rounded-b":[{"rounded-b":A()}],"rounded-l":[{"rounded-l":A()}],"rounded-ss":[{"rounded-ss":A()}],"rounded-se":[{"rounded-se":A()}],"rounded-ee":[{"rounded-ee":A()}],"rounded-es":[{"rounded-es":A()}],"rounded-tl":[{"rounded-tl":A()}],"rounded-tr":[{"rounded-tr":A()}],"rounded-br":[{"rounded-br":A()}],"rounded-bl":[{"rounded-bl":A()}],"border-w":[{border:j()}],"border-w-x":[{"border-x":j()}],"border-w-y":[{"border-y":j()}],"border-w-s":[{"border-s":j()}],"border-w-e":[{"border-e":j()}],"border-w-bs":[{"border-bs":j()}],"border-w-be":[{"border-be":j()}],"border-w-t":[{"border-t":j()}],"border-w-r":[{"border-r":j()}],"border-w-b":[{"border-b":j()}],"border-w-l":[{"border-l":j()}],"divide-x":[{"divide-x":j()}],"divide-x-reverse":[`divide-x-reverse`],"divide-y":[{"divide-y":j()}],"divide-y-reverse":[`divide-y-reverse`],"border-style":[{border:[...M(),`hidden`,`none`]}],"divide-style":[{divide:[...M(),`hidden`,`none`]}],"border-color":[{border:k()}],"border-color-x":[{"border-x":k()}],"border-color-y":[{"border-y":k()}],"border-color-s":[{"border-s":k()}],"border-color-e":[{"border-e":k()}],"border-color-bs":[{"border-bs":k()}],"border-color-be":[{"border-be":k()}],"border-color-t":[{"border-t":k()}],"border-color-r":[{"border-r":k()}],"border-color-b":[{"border-b":k()}],"border-color-l":[{"border-l":k()}],"divide-color":[{divide:k()}],"outline-style":[{outline:[...M(),`none`,`hidden`]}],"outline-offset":[{"outline-offset":[P,I,F]}],"outline-w":[{outline:[``,P,Ct,gt]}],"outline-color":[{outline:k()}],shadow:[{shadow:[``,`none`,u,Ot,St]}],"shadow-color":[{shadow:k()}],"inset-shadow":[{"inset-shadow":[`none`,d,Ot,St]}],"inset-shadow-color":[{"inset-shadow":k()}],"ring-w":[{ring:j()}],"ring-w-inset":[`ring-inset`],"ring-color":[{ring:k()}],"ring-offset-w":[{"ring-offset":[P,gt]}],"ring-offset-color":[{"ring-offset":k()}],"inset-ring-w":[{"inset-ring":j()}],"inset-ring-color":[{"inset-ring":k()}],"text-shadow":[{"text-shadow":[`none`,f,Ot,St]}],"text-shadow-color":[{"text-shadow":k()}],opacity:[{opacity:[P,I,F]}],"mix-blend":[{"mix-blend":[...de(),`plus-darker`,`plus-lighter`]}],"bg-blend":[{"bg-blend":de()}],"mask-clip":[{"mask-clip":[`border`,`padding`,`content`,`fill`,`stroke`,`view`]},`mask-no-clip`],"mask-composite":[{mask:[`add`,`subtract`,`intersect`,`exclude`]}],"mask-image-linear-pos":[{"mask-linear":[P]}],"mask-image-linear-from-pos":[{"mask-linear-from":fe()}],"mask-image-linear-to-pos":[{"mask-linear-to":fe()}],"mask-image-linear-from-color":[{"mask-linear-from":k()}],"mask-image-linear-to-color":[{"mask-linear-to":k()}],"mask-image-t-from-pos":[{"mask-t-from":fe()}],"mask-image-t-to-pos":[{"mask-t-to":fe()}],"mask-image-t-from-color":[{"mask-t-from":k()}],"mask-image-t-to-color":[{"mask-t-to":k()}],"mask-image-r-from-pos":[{"mask-r-from":fe()}],"mask-image-r-to-pos":[{"mask-r-to":fe()}],"mask-image-r-from-color":[{"mask-r-from":k()}],"mask-image-r-to-color":[{"mask-r-to":k()}],"mask-image-b-from-pos":[{"mask-b-from":fe()}],"mask-image-b-to-pos":[{"mask-b-to":fe()}],"mask-image-b-from-color":[{"mask-b-from":k()}],"mask-image-b-to-color":[{"mask-b-to":k()}],"mask-image-l-from-pos":[{"mask-l-from":fe()}],"mask-image-l-to-pos":[{"mask-l-to":fe()}],"mask-image-l-from-color":[{"mask-l-from":k()}],"mask-image-l-to-color":[{"mask-l-to":k()}],"mask-image-x-from-pos":[{"mask-x-from":fe()}],"mask-image-x-to-pos":[{"mask-x-to":fe()}],"mask-image-x-from-color":[{"mask-x-from":k()}],"mask-image-x-to-color":[{"mask-x-to":k()}],"mask-image-y-from-pos":[{"mask-y-from":fe()}],"mask-image-y-to-pos":[{"mask-y-to":fe()}],"mask-image-y-from-color":[{"mask-y-from":k()}],"mask-image-y-to-color":[{"mask-y-to":k()}],"mask-image-radial":[{"mask-radial":[I,F]}],"mask-image-radial-from-pos":[{"mask-radial-from":fe()}],"mask-image-radial-to-pos":[{"mask-radial-to":fe()}],"mask-image-radial-from-color":[{"mask-radial-from":k()}],"mask-image-radial-to-color":[{"mask-radial-to":k()}],"mask-image-radial-shape":[{"mask-radial":[`circle`,`ellipse`]}],"mask-image-radial-size":[{"mask-radial":[{closest:[`side`,`corner`],farthest:[`side`,`corner`]}]}],"mask-image-radial-pos":[{"mask-radial-at":b()}],"mask-image-conic-pos":[{"mask-conic":[P]}],"mask-image-conic-from-pos":[{"mask-conic-from":fe()}],"mask-image-conic-to-pos":[{"mask-conic-to":fe()}],"mask-image-conic-from-color":[{"mask-conic-from":k()}],"mask-image-conic-to-color":[{"mask-conic-to":k()}],"mask-mode":[{mask:[`alpha`,`luminance`,`match`]}],"mask-origin":[{"mask-origin":[`border`,`padding`,`content`,`fill`,`stroke`,`view`]}],"mask-position":[{mask:se()}],"mask-repeat":[{mask:ce()}],"mask-size":[{mask:le()}],"mask-type":[{"mask-type":[`alpha`,`luminance`]}],"mask-image":[{mask:[`none`,I,F]}],filter:[{filter:[``,`none`,I,F]}],blur:[{blur:N()}],brightness:[{brightness:[P,I,F]}],contrast:[{contrast:[P,I,F]}],"drop-shadow":[{"drop-shadow":[``,`none`,p,Ot,St]}],"drop-shadow-color":[{"drop-shadow":k()}],grayscale:[{grayscale:[``,P,I,F]}],"hue-rotate":[{"hue-rotate":[P,I,F]}],invert:[{invert:[``,P,I,F]}],saturate:[{saturate:[P,I,F]}],sepia:[{sepia:[``,P,I,F]}],"backdrop-filter":[{"backdrop-filter":[``,`none`,I,F]}],"backdrop-blur":[{"backdrop-blur":N()}],"backdrop-brightness":[{"backdrop-brightness":[P,I,F]}],"backdrop-contrast":[{"backdrop-contrast":[P,I,F]}],"backdrop-grayscale":[{"backdrop-grayscale":[``,P,I,F]}],"backdrop-hue-rotate":[{"backdrop-hue-rotate":[P,I,F]}],"backdrop-invert":[{"backdrop-invert":[``,P,I,F]}],"backdrop-opacity":[{"backdrop-opacity":[P,I,F]}],"backdrop-saturate":[{"backdrop-saturate":[P,I,F]}],"backdrop-sepia":[{"backdrop-sepia":[``,P,I,F]}],"border-collapse":[{border:[`collapse`,`separate`]}],"border-spacing":[{"border-spacing":C()}],"border-spacing-x":[{"border-spacing-x":C()}],"border-spacing-y":[{"border-spacing-y":C()}],"table-layout":[{table:[`auto`,`fixed`]}],caption:[{caption:[`top`,`bottom`]}],transition:[{transition:[``,`all`,`colors`,`opacity`,`shadow`,`transform`,`none`,I,F]}],"transition-behavior":[{transition:[`normal`,`discrete`]}],duration:[{duration:[P,`initial`,I,F]}],ease:[{ease:[`linear`,`initial`,_,I,F]}],delay:[{delay:[P,I,F]}],animate:[{animate:[`none`,v,I,F]}],backface:[{backface:[`hidden`,`visible`]}],perspective:[{perspective:[h,I,F]}],"perspective-origin":[{"perspective-origin":x()}],rotate:[{rotate:pe()}],"rotate-x":[{"rotate-x":pe()}],"rotate-y":[{"rotate-y":pe()}],"rotate-z":[{"rotate-z":pe()}],scale:[{scale:me()}],"scale-x":[{"scale-x":me()}],"scale-y":[{"scale-y":me()}],"scale-z":[{"scale-z":me()}],"scale-3d":[`scale-3d`],skew:[{skew:he()}],"skew-x":[{"skew-x":he()}],"skew-y":[{"skew-y":he()}],transform:[{transform:[I,F,``,`none`,`gpu`,`cpu`]}],"transform-origin":[{origin:x()}],"transform-style":[{transform:[`3d`,`flat`]}],translate:[{translate:ge()}],"translate-x":[{"translate-x":ge()}],"translate-y":[{"translate-y":ge()}],"translate-z":[{"translate-z":ge()}],"translate-none":[`translate-none`],zoom:[{zoom:[at,I,F]}],accent:[{accent:k()}],appearance:[{appearance:[`none`,`auto`]}],"caret-color":[{caret:k()}],"color-scheme":[{scheme:[`normal`,`dark`,`light`,`light-dark`,`only-dark`,`only-light`]}],cursor:[{cursor:[`auto`,`default`,`pointer`,`wait`,`text`,`move`,`help`,`not-allowed`,`none`,`context-menu`,`progress`,`cell`,`crosshair`,`vertical-text`,`alias`,`copy`,`no-drop`,`grab`,`grabbing`,`all-scroll`,`col-resize`,`row-resize`,`n-resize`,`e-resize`,`s-resize`,`w-resize`,`ne-resize`,`nw-resize`,`se-resize`,`sw-resize`,`ew-resize`,`ns-resize`,`nesw-resize`,`nwse-resize`,`zoom-in`,`zoom-out`,I,F]}],"field-sizing":[{"field-sizing":[`fixed`,`content`]}],"pointer-events":[{"pointer-events":[`auto`,`none`]}],resize:[{resize:[`none`,``,`y`,`x`]}],"scroll-behavior":[{scroll:[`auto`,`smooth`]}],"scrollbar-thumb-color":[{"scrollbar-thumb":k()}],"scrollbar-track-color":[{"scrollbar-track":k()}],"scrollbar-gutter":[{"scrollbar-gutter":[`auto`,`stable`,`both`]}],"scrollbar-w":[{scrollbar:[`auto`,`thin`,`none`]}],"scroll-m":[{"scroll-m":C()}],"scroll-mx":[{"scroll-mx":C()}],"scroll-my":[{"scroll-my":C()}],"scroll-ms":[{"scroll-ms":C()}],"scroll-me":[{"scroll-me":C()}],"scroll-mbs":[{"scroll-mbs":C()}],"scroll-mbe":[{"scroll-mbe":C()}],"scroll-mt":[{"scroll-mt":C()}],"scroll-mr":[{"scroll-mr":C()}],"scroll-mb":[{"scroll-mb":C()}],"scroll-ml":[{"scroll-ml":C()}],"scroll-p":[{"scroll-p":C()}],"scroll-px":[{"scroll-px":C()}],"scroll-py":[{"scroll-py":C()}],"scroll-ps":[{"scroll-ps":C()}],"scroll-pe":[{"scroll-pe":C()}],"scroll-pbs":[{"scroll-pbs":C()}],"scroll-pbe":[{"scroll-pbe":C()}],"scroll-pt":[{"scroll-pt":C()}],"scroll-pr":[{"scroll-pr":C()}],"scroll-pb":[{"scroll-pb":C()}],"scroll-pl":[{"scroll-pl":C()}],"snap-align":[{snap:[`start`,`end`,`center`,`align-none`]}],"snap-stop":[{snap:[`normal`,`always`]}],"snap-type":[{snap:[`none`,`x`,`y`,`both`]}],"snap-strictness":[{snap:[`mandatory`,`proximity`]}],touch:[{touch:[`auto`,`none`,`manipulation`]}],"touch-x":[{"touch-pan":[`x`,`left`,`right`]}],"touch-y":[{"touch-pan":[`y`,`up`,`down`]}],"touch-pz":[`touch-pinch-zoom`],select:[{select:[`none`,`text`,`all`,`auto`]}],"will-change":[{"will-change":[`auto`,`scroll`,`contents`,`transform`,I,F]}],fill:[{fill:[`none`,...k()]}],"stroke-w":[{stroke:[P,Ct,gt,_t]}],stroke:[{stroke:[`none`,...k()]}],"forced-color-adjust":[{"forced-color-adjust":[`auto`,`none`]}]},conflictingClassGroups:{"container-named":[`container-type`],overflow:[`overflow-x`,`overflow-y`],overscroll:[`overscroll-x`,`overscroll-y`],inset:[`inset-x`,`inset-y`,`inset-bs`,`inset-be`,`start`,`end`,`top`,`right`,`bottom`,`left`],"inset-x":[`right`,`left`],"inset-y":[`top`,`bottom`],flex:[`basis`,`grow`,`shrink`],gap:[`gap-x`,`gap-y`],p:[`px`,`py`,`ps`,`pe`,`pbs`,`pbe`,`pt`,`pr`,`pb`,`pl`],px:[`pr`,`pl`],py:[`pt`,`pb`],m:[`mx`,`my`,`ms`,`me`,`mbs`,`mbe`,`mt`,`mr`,`mb`,`ml`],mx:[`mr`,`ml`],my:[`mt`,`mb`],size:[`w`,`h`],"font-size":[`leading`],"fvn-normal":[`fvn-ordinal`,`fvn-slashed-zero`,`fvn-figure`,`fvn-spacing`,`fvn-fraction`],"fvn-ordinal":[`fvn-normal`],"fvn-slashed-zero":[`fvn-normal`],"fvn-figure":[`fvn-normal`],"fvn-spacing":[`fvn-normal`],"fvn-fraction":[`fvn-normal`],"line-clamp":[`display`,`overflow`],rounded:[`rounded-s`,`rounded-e`,`rounded-t`,`rounded-r`,`rounded-b`,`rounded-l`,`rounded-ss`,`rounded-se`,`rounded-ee`,`rounded-es`,`rounded-tl`,`rounded-tr`,`rounded-br`,`rounded-bl`],"rounded-s":[`rounded-ss`,`rounded-es`],"rounded-e":[`rounded-se`,`rounded-ee`],"rounded-t":[`rounded-tl`,`rounded-tr`],"rounded-r":[`rounded-tr`,`rounded-br`],"rounded-b":[`rounded-br`,`rounded-bl`],"rounded-l":[`rounded-tl`,`rounded-bl`],"border-spacing":[`border-spacing-x`,`border-spacing-y`],"border-w":[`border-w-x`,`border-w-y`,`border-w-s`,`border-w-e`,`border-w-bs`,`border-w-be`,`border-w-t`,`border-w-r`,`border-w-b`,`border-w-l`],"border-w-x":[`border-w-r`,`border-w-l`],"border-w-y":[`border-w-t`,`border-w-b`],"border-color":[`border-color-x`,`border-color-y`,`border-color-s`,`border-color-e`,`border-color-bs`,`border-color-be`,`border-color-t`,`border-color-r`,`border-color-b`,`border-color-l`],"border-color-x":[`border-color-r`,`border-color-l`],"border-color-y":[`border-color-t`,`border-color-b`],translate:[`translate-x`,`translate-y`,`translate-none`],"translate-none":[`translate`,`translate-x`,`translate-y`,`translate-z`],"scroll-m":[`scroll-mx`,`scroll-my`,`scroll-ms`,`scroll-me`,`scroll-mbs`,`scroll-mbe`,`scroll-mt`,`scroll-mr`,`scroll-mb`,`scroll-ml`],"scroll-mx":[`scroll-mr`,`scroll-ml`],"scroll-my":[`scroll-mt`,`scroll-mb`],"scroll-p":[`scroll-px`,`scroll-py`,`scroll-ps`,`scroll-pe`,`scroll-pbs`,`scroll-pbe`,`scroll-pt`,`scroll-pr`,`scroll-pb`,`scroll-pl`],"scroll-px":[`scroll-pr`,`scroll-pl`],"scroll-py":[`scroll-pt`,`scroll-pb`],touch:[`touch-x`,`touch-y`,`touch-pz`],"touch-x":[`touch`],"touch-y":[`touch`],"touch-pz":[`touch`]},conflictingClassGroupModifiers:{"font-size":[`leading`]},postfixLookupClassGroups:[`container-type`],orderSensitiveModifiers:[`*`,`**`,`after`,`backdrop`,`before`,`details-content`,`file`,`first-letter`,`first-line`,`marker`,`placeholder`,`selection`]}});function Vt(...e){return Bt(he(e))}function Ht({className:e,...t}){return(0,N.jsx)(`table`,{"data-slot":`table`,className:Vt(`w-full border-collapse text-sm`,e),...t})}function Ut({className:e,...t}){return(0,N.jsx)(`thead`,{"data-slot":`table-header`,className:e,...t})}function Wt({className:e,...t}){return(0,N.jsx)(`tbody`,{"data-slot":`table-body`,className:e,...t})}function Gt({className:e,...t}){return(0,N.jsx)(`tr`,{"data-slot":`table-row`,className:Vt(`border-b transition-colors hover:bg-muted/45`,e),...t})}function L({className:e,...t}){return(0,N.jsx)(`th`,{"data-slot":`table-head`,className:Vt(`bg-secondary px-3 py-3 text-left align-middle text-xs font-extrabold text-muted-foreground`,e),...t})}function R({className:e,...t}){return(0,N.jsx)(`td`,{"data-slot":`table-cell`,className:Vt(`px-3 py-3 align-middle text-sm`,e),...t})}function z({label:e,scope:t,sort:n,sortKey:r,onSort:i}){let a=n.key===r,o=n.direction===`asc`?`↑`:`↓`;return(0,N.jsx)(`button`,{type:`button`,"data-sort-scope":t,"data-sort-key":r,className:`text-left font-[inherit] text-inherit hover:text-foreground`,onClick:()=>i(t,r),children:a?`${e} ${o}`:e})}function B({className:e,label:t,scope:n,sort:r,sortKey:i,onSort:a}){return(0,N.jsx)(L,{className:e,"aria-sort":r.key===i?r.direction===`asc`?`ascending`:`descending`:`none`,children:(0,N.jsx)(z,{label:t,scope:n,sort:r,sortKey:i,onSort:a})})}var V=e=>typeof e==`boolean`?`${e}`:e===0?`0`:e,Kt=he,qt=(e,t)=>n=>{if(t?.variants==null)return Kt(e,n?.class,n?.className);let{variants:r,defaultVariants:i}=t,a=Object.keys(r).map(e=>{let t=n?.[e],a=i?.[e];if(t===null)return null;let o=V(t)||V(a);return r[e][o]}),o=n&&Object.entries(n).reduce((e,t)=>{let[n,r]=t;return r===void 0||(e[n]=r),e},{});return Kt(e,a,t?.compoundVariants?.reduce((e,t)=>{let{class:n,className:r,...a}=t;return Object.entries(a).every(e=>{let[t,n]=e;return Array.isArray(n)?n.includes({...i,...o}[t]):{...i,...o}[t]===n})?[...e,n,r]:e},[]),n?.class,n?.className)},Jt=qt(`inline-flex min-h-[22px] items-center rounded-full border px-2 py-0.5 text-[11px] font-extrabold uppercase leading-tight`,{variants:{variant:{neutral:`border-border bg-secondary text-muted-foreground`,succeeded:`border-emerald-400/35 bg-emerald-500/15 text-emerald-300`,failed:`border-red-400/40 bg-red-500/15 text-red-300`,dead:`border-red-400/40 bg-red-500/15 text-red-300`,missing:`border-red-400/40 bg-red-500/15 text-red-300`,running:`border-amber-400/40 bg-amber-500/15 text-amber-300`,queued:`border-teal-400/40 bg-teal-500/15 text-teal-200`,canceled:`border-border bg-secondary text-muted-foreground`}},defaultVariants:{variant:`neutral`}});function H({className:e,variant:t,...n}){return(0,N.jsx)(`span`,{"data-slot":`badge`,className:Vt(Jt({variant:t,className:e})),...n})}var Yt=qt(`inline-flex min-h-9 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md border text-sm font-semibold transition-colors focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0`,{variants:{variant:{default:`border-primary bg-primary text-primary-foreground hover:bg-primary/90`,secondary:`border-border bg-secondary text-secondary-foreground hover:bg-secondary/80`,outline:`border-border bg-background hover:bg-accent hover:text-accent-foreground`,ghost:`border-transparent hover:bg-accent hover:text-accent-foreground`,destructive:`border-destructive bg-destructive text-white hover:bg-destructive/90`},size:{default:`h-9 px-4 py-2`,sm:`h-8 rounded-md px-3 text-xs`,icon:`size-9`}},defaultVariants:{variant:`secondary`,size:`default`}});function U({className:e,variant:t,size:n,type:r=`button`,...i}){return(0,N.jsx)(`button`,{"data-slot":`button`,type:r,className:Vt(Yt({variant:t,size:n,className:e})),...i})}function W({className:e,...t}){return(0,N.jsx)(`div`,{"data-slot":`card`,className:Vt(`rounded-lg border bg-card text-card-foreground shadow-[0_18px_44px_rgb(0_0_0/0.22)]`,e),...t})}function G({className:e,...t}){return(0,N.jsx)(`div`,{"data-slot":`card-header`,className:Vt(`flex items-center justify-between gap-3 border-b px-4 py-3`,e),...t})}function K({className:e,...t}){return(0,N.jsx)(`h2`,{"data-slot":`card-title`,className:Vt(`text-[15px] font-bold`,e),...t})}function Xt({className:e,...t}){return(0,N.jsx)(`div`,{"data-slot":`card-content`,className:Vt(`p-4`,e),...t})}function q({className:e,type:t,...n}){return(0,N.jsx)(`input`,{"data-slot":`input`,type:t,className:Vt(`flex min-h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50`,e),...n})}function J({className:e,...t}){return(0,N.jsx)(`label`,{"data-slot":`label`,className:Vt(`grid gap-1.5 text-xs font-bold text-muted-foreground`,e),...t})}function Y({className:e,...t}){return(0,N.jsx)(`select`,{"data-slot":`select`,className:Vt(`flex min-h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-xs transition-colors focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50`,e),...t})}var Zt={pending:`Needs review`,selected:`Assigned to onboarder`,reachingout:`Reaching out`,awaitingcontribution:`Awaiting contribution`,onboarded:`Onboarded`,waitlist:`Waitlist`,rejected:`Rejected`};function Qt(e){if(!e)return``;let t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString(void 0,{year:`numeric`,month:`short`,day:`numeric`,hour:`2-digit`,minute:`2-digit`})}function $t(e,t=new Date){if(!e)return null;let n=new Date(e);if(Number.isNaN(n.getTime()))return null;let r=t.getTime()-n.getTime();return r<0?0:Math.floor(r/864e5)}function en(e){return e==null?``:JSON.stringify(e,null,2)}function tn(e){return[`succeeded`,`dead`,`canceled`].includes(String(e||``).trim().toLowerCase())}function nn(e){return e===`llm`?`LLM`:e===`heuristic`?`Keyword fallback`:`Unknown`}function rn(e){return e.onboarding_state||e.onboardingState||e.cOnboardingState||``}function an(e){let t=String(e||``).trim();if(!t)return`No status`;let n=t.toLowerCase();return Zt[n]?Zt[n]:t.replace(/[-_]+/g,` `).replace(/\s+/g,` `).trim().replace(/\b\w/g,e=>e.toUpperCase())}function on(e){let t=String(e||``).trim().toLowerCase();return!t||t===`pending`?`neutral`:t===`selected`?`queued`:t===`rejected`?`failed`:t===`onboarded`?`succeeded`:t===`waitlist`?`running`:`queued`}function sn(e){let t=String(e||``).trim();return!t||t.toLowerCase()===`none`?``:t}function cn(e){let t=String(e||``).trim();return t?/^https?:\/\//i.test(t)?t:`https://${t.replace(/^\/+/,``)}`:``}function ln(e){try{return new URL(cn(e))}catch{return null}}function un(e,t){let n=e.toLowerCase();return n===t||n.endsWith(`.${t}`)}function dn(e){return e.split(`/`).filter(Boolean).map(e=>encodeURIComponent(e)).join(`/`)}function fn(e){let t=String(e||``).trim();if(!t)return``;let n=ln(t);if(n&&un(n.hostname,`linkedin.com`))return n.href;if(/^https?:\/\//i.test(t))return``;let r=t.replace(/^@/,``).replace(/^\/+|\/+$/g,``).replace(/^in\//i,``);return r?`https://www.linkedin.com/in/${dn(r)}`:``}function pn(e){let t=String(e||``).trim().replace(/^@/,``);if(!t)return``;let n=ln(t);if(n&&un(n.hostname,`github.com`))return n.href;if(/^https?:\/\//i.test(t))return``;let r=t.replace(/^\/+|\/+$/g,``);return r?`https://github.com/${dn(r)}`:``}var mn=[{value:`part_time`,label:`Part-time / contract`},{value:`full_time`,label:`Full-time`},{value:`part_time_or_full_time`,label:`Part-time or full-time`},{value:`unknown`,label:`Unknown`}],hn=[{category:`CRM`,label:`CRM`,description:`EspoCRM connection settings used by the API, worker, and Discord bot.`},{category:`Projects`,label:`Projects`,description:`ERPNext credentials and project workflow settings.`},{category:`Onboarding`,label:`Onboarding`,description:`Editable onboarding integrations such as DocuSeal, Outline, and onboarding email SMTP.`},{category:`Newsletter`,label:`Newsletter`,description:`Brevo, Keila, and recurring 508 members audience sync settings.`},{category:`AI`,label:`AI Providers`,description:`Provider credentials, base URLs, and model defaults.`},{category:`Agent`,label:`Agent Runtime`,description:`Planner, fallback, and tiered model routing for agent workflows.`},{category:`Observability`,label:`Observability`,description:`Telemetry and request tracing integrations.`},{category:`Intake`,label:`Intake`,description:`Resume and mailbox intake limits and parser defaults.`},{category:`Operations`,label:`Operations`,description:`Queue, sync, GitHub, and notification behavior.`},{category:`Legacy`,label:`Legacy`,description:`Older integrations retained for compatibility.`}],gn=new Map(hn.map((e,t)=>[e.category,{...e,index:t}]));function _n(e){return`configurationGroup-${e.replace(/[^a-zA-Z0-9_-]+/g,`-`)}`}function vn(e=window.location.hash){return e.replace(/^#/,``)===`job-channels`?`job-channels`:`settings`}function yn(e){let t=e===`job-channels`?`#job-channels`:`#settings`;window.history.replaceState(window.history.state,``,`${window.location.pathname}${window.location.search}${t}`)}function bn(e){return mn.some(t=>t.value===e)?e:`unknown`}function xn(e){return e.key.startsWith(`ONBOARDING_EMAIL_`)||e.is_secret||e.value_type===`url`||e.key.endsWith(`_MODEL`)||e.key.endsWith(`_API_USER`)||e.key.endsWith(`_BASE_URL`)}function Sn({items:e,loading:t,canWrite:n,onRefresh:r,jobChannels:i,availableJobChannels:a,onRefreshJobChannels:o,onSave:s,onClear:c,onSaveJobChannel:u,onDeleteJobChannel:d,focusCategory:f,focusNonce:p}){let[m,h]=(0,l.useState)(vn),[g,_]=(0,l.useState)(`All`),[v,y]=(0,l.useState)(``),[b,x]=(0,l.useState)(``),[S,ee]=(0,l.useState)(`part_time`),[C,w]=(0,l.useState)({}),[T,re]=(0,l.useState)({}),ie=(0,l.useMemo)(()=>new Set(i.map(e=>e.channel_id)),[i]),ae=(0,l.useMemo)(()=>{let e=new Map;for(let t of i)e.set(t.channel_id,t);for(let t of a)e.set(t.channel_id,{...e.get(t.channel_id),...t});return Array.from(e.values()).sort((e,t)=>(e.channel_name||e.channel_id).localeCompare(t.channel_name||t.channel_id))},[a,i]),E=ae.filter(e=>!ie.has(e.channel_id)),D=(0,l.useMemo)(()=>{let t=new Set(e.map(e=>e.category)),n=hn.filter(e=>t.has(e.category)),r=Array.from(t).filter(e=>!gn.has(e)).sort().map(e=>({category:e,label:e,description:`Additional runtime settings.`}));return n.concat(r)},[e]),O=(0,l.useMemo)(()=>{let t=new Map;for(let n of e){if(g!==`All`&&n.category!==g)continue;let e=t.get(n.category)??[];e.push(n),t.set(n.category,e)}return Array.from(t.entries()).map(([e,t])=>{let n=gn.get(e),r=t.sort((e,t)=>Number(!xn(e))-Number(!xn(t))||e.label.localeCompare(t.label)),i=r.filter(xn),a=r.filter(e=>!xn(e));return{category:e,label:n?.label??e,description:n?.description??`Additional runtime settings.`,order:n?.index??hn.length,primaryItems:i.length?i:r,advancedItems:i.length?a:[],items:r}}).sort((e,t)=>e.order-t.order||e.label.localeCompare(t.label))},[e,g]),k=(0,l.useMemo)(()=>({configured:e.filter(e=>e.configured).length,envLocked:e.filter(e=>e.env_locked).length,missing:e.filter(e=>!e.configured).length}),[e]),se=O.reduce((e,t)=>e+t.items.length,0);(0,l.useEffect)(()=>{let e=()=>h(vn());return window.addEventListener(`hashchange`,e),()=>window.removeEventListener(`hashchange`,e)},[]),(0,l.useEffect)(()=>{b&&ae.some(e=>e.channel_id===b)||x((E[0]||ae[0])?.channel_id||``)},[ae,b,E]),(0,l.useEffect)(()=>{let e=ae.find(e=>e.channel_id===b);if(!e)return;let t=bn(String(e.posting_type||``));ee(ie.has(e.channel_id)?t:`part_time`)},[ae,ie,b]),(0,l.useEffect)(()=>{w(Object.fromEntries(e.map(e=>[e.key,e.is_secret?``:String(e.value??``)])))},[e]),(0,l.useEffect)(()=>{g!==`All`&&!e.some(e=>e.category===g)&&_(`All`)},[e,g]),(0,l.useEffect)(()=>{if(!f||!e.some(e=>e.category===f))return;h(`settings`),yn(`settings`),_(f),y(f);let t=window.requestAnimationFrame?.(()=>{let e=document.getElementById(_n(f));typeof e?.scrollIntoView==`function`&&e.scrollIntoView({block:`start`,behavior:`smooth`})}),n=window.setTimeout(()=>y(``),4e3);return()=>{t!==void 0&&window.cancelAnimationFrame?.(t),window.clearTimeout(n)}},[f,p,e]);function ce(e){return e.source===`env`?`ENV`:e.source===`database`?`DB`:`Default`}function le(e){h(e),yn(e)}function ue(e){return mn.find(t=>t.value===e)?.label??e}function A(e){let t=e.channel_name?`#${e.channel_name}`:`#${e.channel_id}`;return ie.has(e.channel_id)?`${t} (registered)`:t}function j(){let e=new Uint8Array(32);return window.crypto.getRandomValues(e),Array.from(e,e=>e.toString(16).padStart(2,`0`)).join(``)}async function M(e){let t=j();w(n=>({...n,[e.key]:t})),await s(e.key,t)&&re(n=>({...n,[e.key]:t}))}async function de(e){let t=T[e];if(t){if(navigator.clipboard?.writeText)try{await navigator.clipboard.writeText(t);return}catch{}document.getElementById(`generatedSecret-${e}`)?.select()}}function fe(e){let r=C[e.key]??``,i=!n||e.env_locked||t[`configuration:${e.key}`];return e.value_type===`bool`?(0,N.jsxs)(Y,{"aria-label":`${e.label} value`,value:r,disabled:i,onChange:t=>w(n=>({...n,[e.key]:t.target.value})),children:[(0,N.jsx)(`option`,{value:``,children:`Default`}),(0,N.jsx)(`option`,{value:`true`,children:`True`}),(0,N.jsx)(`option`,{value:`false`,children:`False`})]}):(0,N.jsx)(q,{"aria-label":`${e.label} value`,value:r,type:e.is_secret?`password`:e.value_type===`int`?`number`:`text`,inputMode:e.value_type===`int`||e.value_type===`float`?`numeric`:`text`,placeholder:e.is_secret?`Set new value`:``,autoComplete:`off`,disabled:i,onChange:t=>w(n=>({...n,[e.key]:t.target.value}))})}function me(e,t,n){return(0,N.jsx)(`div`,{className:`overflow-x-auto`,children:(0,N.jsxs)(Ht,{id:`configurationTable-${n}`,className:`min-w-[980px]`,"aria-label":`${e} configuration settings`,children:[(0,N.jsx)(Ut,{children:(0,N.jsxs)(Gt,{children:[(0,N.jsx)(L,{className:`w-[26%]`,children:`Setting`}),(0,N.jsx)(L,{className:`w-[12%]`,children:`Source`}),(0,N.jsx)(L,{className:`w-[18%]`,children:`Active`}),(0,N.jsx)(L,{className:`w-[29%]`,children:`Value`}),(0,N.jsx)(L,{className:`w-[15%]`,children:`Actions`})]})}),(0,N.jsx)(Wt,{id:`configurationBody-${n}`,children:t.map(e=>he(e))})]})})}function he(e){let r=t[`configuration:${e.key}`],i=n&&!e.env_locked&&!r,a=C[e.key]??``,o=!e.is_secret&&!a.trim(),l=T[e.key]||``,u=e.key===`ONBOARDING_TALLY_WEBHOOK_SIGNING_SECRET`;return(0,N.jsxs)(Gt,{children:[(0,N.jsx)(R,{children:(0,N.jsxs)(`div`,{className:`grid gap-1`,children:[(0,N.jsx)(`strong`,{children:e.label}),(0,N.jsx)(`span`,{className:`font-mono text-xs text-muted-foreground`,children:e.key}),(0,N.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:e.description}),e.restart_required?(0,N.jsx)(`div`,{children:(0,N.jsx)(H,{variant:`running`,children:`Restart`})}):null]})}),(0,N.jsx)(R,{children:(0,N.jsxs)(`div`,{className:`grid gap-1.5`,children:[(0,N.jsx)(H,{variant:e.source===`env`?`running`:`neutral`,children:ce(e)}),e.env_locked?(0,N.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`Environment locked`}):null]})}),(0,N.jsx)(R,{children:(0,N.jsxs)(`div`,{className:`grid gap-1`,children:[(0,N.jsx)(H,{variant:e.configured?`succeeded`:`missing`,children:e.configured?`Configured`:`Missing`}),e.is_secret?(0,N.jsx)(`span`,{className:`font-mono text-xs text-muted-foreground`,children:e.masked_value||(e.configured?`Hidden`:`No secret`)}):(0,N.jsx)(`span`,{className:`break-words text-xs text-muted-foreground`,children:String(e.value??``)||`Default`}),e.is_secret&&e.secret_encryption_configured===!1?(0,N.jsx)(`span`,{className:`text-xs text-red-300`,children:`Encryption key missing`}):null]})}),(0,N.jsx)(R,{children:(0,N.jsxs)(`div`,{className:`grid gap-2`,children:[fe(e),u&&l?(0,N.jsxs)(`div`,{className:`grid gap-2 rounded-md border bg-secondary/30 p-2 text-xs`,children:[(0,N.jsx)(`span`,{className:`font-extrabold`,children:`Copy this secret into Tally now.`}),(0,N.jsx)(q,{id:`generatedSecret-${e.key}`,value:l,readOnly:!0,className:`font-mono`,"aria-label":`Generated Tally webhook signing secret`}),(0,N.jsx)(`span`,{className:`text-muted-foreground`,children:`It is only shown until this page refreshes or you dismiss it.`})]}):null]})}),(0,N.jsx)(R,{children:(0,N.jsxs)(`div`,{className:`flex flex-wrap justify-end gap-2`,children:[u?(0,N.jsx)(U,{type:`button`,size:`sm`,variant:`outline`,onClick:()=>void M(e),disabled:!i||e.secret_encryption_configured===!1||!window.crypto?.getRandomValues,children:`Generate`}):null,u&&l?(0,N.jsxs)(N.Fragment,{children:[(0,N.jsx)(U,{type:`button`,size:`sm`,variant:`outline`,onClick:()=>void de(e.key),children:`Copy`}),(0,N.jsx)(U,{type:`button`,size:`sm`,variant:`ghost`,onClick:()=>re(t=>{let n={...t};return delete n[e.key],n}),children:`Hide`})]}):null,(0,N.jsx)(U,{type:`button`,size:`sm`,onClick:()=>s(e.key,a),disabled:!i||e.is_secret&&!a.trim()||o||e.is_secret&&e.secret_encryption_configured===!1,children:`Save`}),(0,N.jsx)(U,{type:`button`,size:`sm`,variant:`outline`,onClick:()=>c(e.key),disabled:!i||e.source!==`database`,children:`Clear`})]})})]},e.key)}function ge(){let e=ae.find(e=>e.channel_id===b),r=b?ie.has(b):!1,a=b?t[`jobPostChannel:${b}`]:!1;return(0,N.jsxs)(`div`,{className:`grid gap-4`,children:[(0,N.jsx)(`section`,{className:`grid gap-3 sm:grid-cols-3`,"aria-label":`Job channel summary`,children:[[`Registered`,i.length],[`Forum channels`,ae.length],[`Available to add`,E.length]].map(([e,t])=>(0,N.jsxs)(`div`,{className:`rounded-md border bg-background p-3`,children:[(0,N.jsx)(`span`,{className:`text-[11px] font-extrabold uppercase text-muted-foreground`,children:e}),(0,N.jsx)(`strong`,{className:`mt-1 block text-xl`,children:t})]},e))}),(0,N.jsxs)(`section`,{className:`grid gap-3 rounded-md border bg-background p-4`,children:[(0,N.jsxs)(`div`,{className:`grid gap-3 lg:grid-cols-[minmax(0,1.5fr)_minmax(180px,0.7fr)_auto]`,children:[(0,N.jsxs)(Y,{"aria-label":`Job channel to register`,value:b,disabled:!n||t.jobPostChannels||ae.length===0,onChange:e=>x(e.target.value),children:[ae.length===0?(0,N.jsx)(`option`,{value:``,children:`No Discord forum channels loaded`}):null,ae.map(e=>(0,N.jsx)(`option`,{value:e.channel_id,children:A(e)},e.channel_id))]}),(0,N.jsx)(Y,{"aria-label":`New job channel posting type`,value:S,disabled:!n||t.jobPostChannels||ae.length===0,onChange:e=>ee(e.target.value),children:mn.map(e=>(0,N.jsx)(`option`,{value:e.value,children:e.label},e.value))}),(0,N.jsxs)(U,{type:`button`,onClick:()=>void u(b,S),disabled:!n||!b||a,children:[(0,N.jsx)(te,{}),r?`Update channel`:`Register channel`]})]}),e?(0,N.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[`Selected channel ID: `,e.channel_id]}):null]}),(0,N.jsx)(pe,{hidden:i.length!==0,children:`No job channels registered.`}),i.length?(0,N.jsx)(`div`,{className:`overflow-x-auto rounded-md border`,children:(0,N.jsxs)(Ht,{className:`min-w-[760px]`,"aria-label":`Registered job channels`,children:[(0,N.jsx)(Ut,{children:(0,N.jsxs)(Gt,{children:[(0,N.jsx)(L,{className:`w-[32%]`,children:`Channel`}),(0,N.jsx)(L,{className:`w-[24%]`,children:`Posting type`}),(0,N.jsx)(L,{className:`w-[24%]`,children:`Tags`}),(0,N.jsx)(L,{className:`w-[20%]`,children:`Actions`})]})}),(0,N.jsx)(Wt,{children:i.map(e=>{let r=t[`jobPostChannel:${e.channel_id}`],i=bn(String(e.posting_type||``));return(0,N.jsxs)(Gt,{children:[(0,N.jsx)(R,{children:(0,N.jsxs)(`div`,{className:`grid gap-1`,children:[(0,N.jsx)(`strong`,{children:e.channel_name?`#${e.channel_name}`:`#${e.channel_id}`}),(0,N.jsx)(`span`,{className:`font-mono text-xs text-muted-foreground`,children:e.channel_id})]})}),(0,N.jsx)(R,{children:(0,N.jsx)(Y,{"aria-label":`${e.channel_name||e.channel_id} posting type`,value:i,disabled:!n||r,onChange:t=>void u(e.channel_id,t.target.value),children:mn.map(e=>(0,N.jsx)(`option`,{value:e.value,children:e.label},e.value))})}),(0,N.jsx)(R,{children:(0,N.jsxs)(`div`,{className:`flex flex-wrap gap-1.5`,children:[e.requires_tag?(0,N.jsx)(H,{variant:`running`,children:`Requires tag`}):null,(0,N.jsxs)(H,{variant:`neutral`,children:[(e.available_tags||[]).length,` tags`]}),(0,N.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:ue(i)})]})}),(0,N.jsx)(R,{children:(0,N.jsxs)(U,{type:`button`,size:`sm`,variant:`destructive`,title:`Deregister channel`,onClick:()=>d(e.channel_id),disabled:!n||r,children:[(0,N.jsx)(oe,{}),`Deregister`]})})]},e.channel_id)})})]})}):null]})}return(0,N.jsxs)(`div`,{className:`grid gap-4`,children:[(0,N.jsxs)(W,{children:[(0,N.jsxs)(G,{children:[(0,N.jsxs)(`div`,{className:`grid gap-3`,children:[(0,N.jsx)(K,{children:`Configuration`}),(0,N.jsxs)(`div`,{className:`inline-flex w-fit rounded-md border bg-background p-1`,role:`tablist`,"aria-label":`Configuration tabs`,children:[(0,N.jsx)(U,{id:`configurationSettingsTab`,type:`button`,size:`sm`,variant:m===`settings`?`default`:`ghost`,"aria-pressed":m===`settings`,onClick:()=>le(`settings`),children:`Settings`}),(0,N.jsxs)(U,{id:`configurationJobChannelsTab`,type:`button`,size:`sm`,variant:m===`job-channels`?`default`:`ghost`,"aria-pressed":m===`job-channels`,onClick:()=>le(`job-channels`),children:[`Job channels`,(0,N.jsx)(`span`,{className:`font-mono text-[11px]`,children:i.length})]})]})]}),(0,N.jsxs)(U,{id:`refreshConfiguration`,type:`button`,variant:`outline`,onClick:m===`job-channels`?o:r,disabled:m===`job-channels`?t.jobPostChannels:t.configuration,children:[(0,N.jsx)(ne,{}),`Refresh`]})]}),m===`settings`?(0,N.jsxs)(Xt,{className:`grid gap-4`,children:[(0,N.jsx)(`section`,{className:`grid gap-3 sm:grid-cols-2 lg:grid-cols-4`,"aria-label":`Configuration summary`,children:[[`Total`,e.length],[`Configured`,k.configured],[`Missing`,k.missing],[`Env locked`,k.envLocked]].map(([e,t])=>(0,N.jsxs)(`div`,{className:`rounded-md border bg-background p-3`,children:[(0,N.jsx)(`span`,{className:`text-[11px] font-extrabold uppercase text-muted-foreground`,children:e}),(0,N.jsx)(`strong`,{className:`mt-1 block text-xl`,children:t})]},e))}),(0,N.jsxs)(`section`,{className:`flex flex-wrap gap-2`,"aria-label":`Configuration groups`,children:[(0,N.jsxs)(U,{type:`button`,size:`sm`,variant:g===`All`?`default`:`outline`,"aria-pressed":g===`All`,onClick:()=>_(`All`),children:[`All groups`,(0,N.jsx)(`span`,{className:`font-mono text-[11px]`,children:e.length})]}),D.map(t=>{let n=e.filter(e=>e.category===t.category).length;return(0,N.jsxs)(U,{type:`button`,size:`sm`,variant:g===t.category?`default`:`outline`,"aria-pressed":g===t.category,onClick:()=>_(t.category),children:[t.label,(0,N.jsx)(`span`,{className:`font-mono text-[11px]`,children:n})]},t.category)})]})]}):(0,N.jsx)(Xt,{children:ge()})]}),m===`settings`?(0,N.jsx)(pe,{hidden:se!==0,children:`No configuration entries found.`}):null,m===`settings`?O.map(e=>{let t=e.items.filter(e=>e.configured).length,n=e.items.length-t,r=e.items.some(e=>e.restart_required);return(0,N.jsxs)(W,{id:_n(e.category),className:Vt(`scroll-mt-4 transition-shadow`,v===e.category&&`ring-2 ring-primary ring-offset-2 ring-offset-background`),children:[(0,N.jsxs)(G,{className:`items-start`,children:[(0,N.jsxs)(`div`,{className:`grid gap-1`,children:[(0,N.jsx)(K,{children:e.label}),(0,N.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:e.description})]}),(0,N.jsxs)(`div`,{className:`flex flex-wrap justify-end gap-1.5`,children:[(0,N.jsxs)(H,{variant:`neutral`,children:[e.items.length,` settings`]}),(0,N.jsxs)(H,{variant:n?`missing`:`succeeded`,children:[t,` configured`]}),r?(0,N.jsx)(H,{variant:`running`,children:`Restart`}):null]})]}),me(e.label,e.primaryItems,e.category),e.advancedItems.length?(0,N.jsxs)(`details`,{className:`border-t bg-background/40`,children:[(0,N.jsxs)(`summary`,{className:`flex min-h-11 cursor-pointer items-center justify-between gap-3 px-4 py-3 text-sm font-extrabold`,children:[(0,N.jsx)(`span`,{children:`Advanced`}),(0,N.jsxs)(`span`,{className:`font-mono text-xs text-muted-foreground`,children:[e.advancedItems.length,` settings`]})]}),(0,N.jsx)(`div`,{className:`border-t`,children:me(`${e.label} advanced`,e.advancedItems,`${e.category}-advanced`)})]}):null]},e.category)}):null]})}function Cn(e){if(!e)return null;let t=Object.entries(e.providers||{}).map(([e,t])=>`${e}: ${t.would_sync??t.synced??0} would sync, ${t.skipped||0} skipped, ${t.failed||0} failed`),n=e.mailboxes_scanned??0,r=e.contacts_considered??0;return[`${n} mailbox${n===1?``:`es`}`,`${r} contact${r===1?``:`s`}`,...t].join(`; `)}function wn(e){let t=Number(e||0);if(!Number.isFinite(t)||t<=0)return`Not configured`;if(t%86400==0){let e=t/86400;return`${e} day${e===1?``:`s`}`}if(t%3600==0){let e=t/3600;return`${e} hour${e===1?``:`s`}`}if(t%60==0){let e=t/60;return`${e} minute${e===1?``:`s`}`}return`${t} second${t===1?``:`s`}`}function Tn(e){let t=e?.result;if(!t||typeof t!=`object`||Array.isArray(t))return[];let n=t.providers;return!n||typeof n!=`object`||Array.isArray(n)?[]:Object.entries(n).filter(e=>{let[,t]=e;return!!(t&&typeof t==`object`&&!Array.isArray(t))}).sort(([e],[t])=>e.localeCompare(t))}function En(e){let t=e.status?.latest_job||null,n=e.status?.active_suppressed_email_count,r=e.status?.active_suppression_count,i=Tn(t),a=e.providerFilter?[...new Set([...e.providerOptions,e.providerFilter])].sort((e,t)=>e.localeCompare(t)):e.providerOptions,o=e.suppressions.reduce((e,t)=>{let n=t.source_provider||`unknown`;return e[n]=[...e[n]||[],t],e},{}),s=Object.entries(o).sort(([e],[t])=>e.localeCompare(t));return(0,N.jsxs)(N.Fragment,{children:[(0,N.jsxs)(W,{children:[(0,N.jsxs)(G,{children:[(0,N.jsx)(K,{children:`Newsletter sync`}),(0,N.jsxs)(`div`,{className:`flex flex-wrap items-center justify-end gap-2`,children:[(0,N.jsxs)(U,{id:`refreshNewsletter`,type:`button`,variant:`outline`,onClick:e.onRefresh,disabled:e.loading.newsletterStatus||e.loading.newsletterSuppressions,children:[(0,N.jsx)(ne,{}),`Refresh`]}),e.canSync?(0,N.jsxs)(U,{id:`syncNewsletters`,"data-permission":`people:sync`,type:`button`,onClick:e.onSync,disabled:e.loading.syncNewsletters,children:[(0,N.jsx)(ne,{}),`Sync now`]}):null]})]}),(0,N.jsxs)(Xt,{children:[(0,N.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-4`,children:[(0,N.jsxs)(`div`,{className:`rounded-md border bg-background p-4`,children:[(0,N.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Scheduler`}),(0,N.jsx)(`strong`,{id:`newsletterScheduler`,className:`block text-2xl`,children:e.status?.scheduler_enabled?`Enabled`:`Disabled`})]}),(0,N.jsxs)(`div`,{className:`rounded-md border bg-background p-4`,children:[(0,N.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Interval`}),(0,N.jsx)(`strong`,{id:`newsletterInterval`,className:`block text-2xl`,children:wn(e.status?.interval_seconds)})]}),(0,N.jsxs)(`div`,{className:`rounded-md border bg-background p-4`,children:[(0,N.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Suppressed emails`}),(0,N.jsx)(`strong`,{id:`newsletterSuppressedEmails`,className:`block text-2xl`,children:n??`Loading`})]}),(0,N.jsxs)(`div`,{className:`rounded-md border bg-background p-4`,children:[(0,N.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Suppression rows`}),(0,N.jsx)(`strong`,{id:`newsletterSuppressionRows`,className:`block text-2xl`,children:r??`Loading`})]})]}),(0,N.jsxs)(`div`,{className:`mt-4 grid gap-2 rounded-md border bg-secondary p-3 text-sm`,children:[(0,N.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[(0,N.jsx)(`span`,{className:`font-extrabold`,children:`Latest sync`}),t?(0,N.jsx)(H,{variant:t.status||`neutral`,children:t.status}):(0,N.jsx)(H,{variant:`neutral`,children:`No job found`})]}),t?(0,N.jsxs)(N.Fragment,{children:[(0,N.jsxs)(`div`,{className:`text-muted-foreground`,children:[t.type,` | updated `,Qt(t.updated_at),` | attempts`,` `,t.attempts,`/`,t.max_attempts]}),t.last_error?(0,N.jsx)(`div`,{className:`text-red-700 dark:text-red-300`,children:t.last_error}):null,(0,N.jsx)(`div`,{className:`grid gap-2 md:grid-cols-2`,children:i.length?i.map(([e,t])=>{let n=t.statuses||{},r=Object.entries(n).sort(([e],[t])=>e.localeCompare(t)),i=t.synced??t.would_sync??0;return(0,N.jsxs)(`div`,{className:`grid gap-2 rounded-md border bg-background p-3`,children:[(0,N.jsxs)(`div`,{className:`flex flex-wrap items-center justify-between gap-2`,children:[(0,N.jsx)(`strong`,{children:e}),(0,N.jsxs)(`div`,{className:`flex flex-wrap gap-1`,children:[(0,N.jsxs)(H,{variant:`succeeded`,children:[i,` synced`]}),(0,N.jsxs)(H,{variant:`neutral`,children:[t.skipped||0,` skipped`]}),t.failed?(0,N.jsxs)(H,{variant:`failed`,children:[t.failed,` failed`]}):null]})]}),r.length?(0,N.jsx)(`div`,{className:`flex flex-wrap gap-1`,children:r.map(([e,t])=>(0,N.jsxs)(H,{variant:`neutral`,children:[e.replaceAll(`_`,` `),`: `,t]},e))}):null]},e)}):(0,N.jsx)(`div`,{className:`rounded-md border bg-background p-3 text-muted-foreground`,children:`No provider status details recorded for the latest sync.`})}),t.result?(0,N.jsx)(`pre`,{className:`max-h-40 overflow-auto rounded-md bg-background p-3 text-xs`,children:en(t.result)}):null]}):(0,N.jsx)(`div`,{className:`text-muted-foreground`,children:`No 508 members newsletter sync job was found in the last 90 days.`})]})]})]}),(0,N.jsxs)(W,{children:[(0,N.jsxs)(G,{children:[(0,N.jsx)(K,{children:`Newsletter suppressions`}),(0,N.jsxs)(`div`,{className:`flex flex-wrap items-center justify-end gap-3`,children:[(0,N.jsxs)(J,{className:`min-w-44`,children:[`Provider`,(0,N.jsxs)(Y,{id:`newsletterProviderFilter`,value:e.providerFilter,onChange:t=>e.onProviderFilterChange(t.target.value),children:[(0,N.jsx)(`option`,{value:``,children:`All providers`}),a.map(e=>(0,N.jsx)(`option`,{value:e,children:e},e))]})]}),(0,N.jsx)(`span`,{id:`newsletterSuppressionsStatus`,className:`text-sm text-muted-foreground`,children:e.loading.newsletterSuppressions?`Loading`:`${e.suppressions.length} shown`})]})]}),(0,N.jsx)(pe,{hidden:e.suppressions.length!==0,children:`No active newsletter suppressions recorded.`}),(0,N.jsx)(`div`,{className:Vt(`grid gap-4`,e.suppressions.length===0&&`hidden`),children:s.map(([t,n])=>(0,N.jsxs)(`section`,{className:`grid gap-2`,"aria-label":`${t} suppressions`,children:[(0,N.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2 px-4 pt-4`,children:[(0,N.jsx)(H,{variant:`neutral`,children:t}),(0,N.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[n.length,` suppression`,n.length===1?``:`s`]})]}),(0,N.jsx)(`div`,{className:`overflow-x-auto`,children:(0,N.jsxs)(Ht,{id:`newsletterSuppressionsTable-${t}`,className:`min-w-[900px]`,"aria-label":`${t} newsletter suppressions`,children:[(0,N.jsx)(Ut,{children:(0,N.jsxs)(Gt,{children:[(0,N.jsx)(B,{className:`w-[34%]`,label:`Email`,scope:`newsletter`,sort:e.sort,sortKey:`email`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[14%]`,label:`Source`,scope:`newsletter`,sort:e.sort,sortKey:`source_provider`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[22%]`,label:`Reason`,scope:`newsletter`,sort:e.sort,sortKey:`reason`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[15%]`,label:`First seen`,scope:`newsletter`,sort:e.sort,sortKey:`first_seen_at`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[15%]`,label:`Last seen`,scope:`newsletter`,sort:e.sort,sortKey:`last_seen_at`,onSort:(t,n)=>e.onSort(n)})]})}),(0,N.jsx)(Wt,{id:`newsletterSuppressionsBody-${t}`,children:n.map(e=>(0,N.jsxs)(Gt,{children:[(0,N.jsx)(R,{className:`font-mono text-sm`,children:e.email}),(0,N.jsx)(R,{children:(0,N.jsx)(H,{variant:`neutral`,children:e.source_provider})}),(0,N.jsx)(R,{children:e.reason.replaceAll(`_`,` `)}),(0,N.jsx)(R,{children:Qt(e.first_seen_at)}),(0,N.jsx)(R,{children:Qt(e.last_seen_at||e.updated_at)})]},`${e.email}-${e.source_provider}`))})]})})]},t))})]})]})}var Dn=[{value:`transaction.counterparty`,label:`Counterparty`},{value:`transaction.description`,label:`Description`},{value:`transaction.reference_number`,label:`Reference number`}];function On(e){return`${e.fact.replace(`transaction.`,``)} ${e.operator.replaceAll(`_`,` `)} ${typeof e.value==`string`?e.value:e.value===void 0?``:JSON.stringify(e.value)}`.trim()}function kn(e,t){let n=e[t];return typeof n==`string`&&n.trim()?n.trim():``}function An(e){return[kn(e,`amount`),kn(e,`currency`)].filter(Boolean).join(` `)||`Amount unavailable`}function jn(e){let[t,n]=(0,l.useState)(``),[r,i]=(0,l.useState)(`transaction.counterparty`),[a,o]=(0,l.useState)(``),[s,c]=(0,l.useState)(``),[u,d]=(0,l.useState)(``),[f,p]=(0,l.useState)(`suggest`),[m,h]=(0,l.useState)(`0`),[g,_]=(0,l.useState)(``),[y,b]=(0,l.useState)(null),x=(0,l.useMemo)(()=>new Map(e.projects.map(e=>[e.id,e])),[e.projects]),S=t?e.rules.filter(e=>e.project_id===t):e.rules,C=t?e.suggestions.filter(e=>e.project_id===t):e.suggestions,w=s.trim().toUpperCase(),T=u.trim(),re=e.canWrite&&!!(t&&a.trim()&&w)&&(f!==`automatic`||!!T);function ie(e){return e?x.get(e)?.display_name||e:`Unknown project`}async function ae(n){if(n.preventDefault(),!re){_(f===`automatic`?`Automatic rules require a project, identity match, exact currency, and exact amount.`:`Choose a project and provide an identity match and currency.`);return}let i=Number(m);if(!Number.isInteger(i)||i<-1e4||i>1e4){_(`Priority must be a whole number between -10,000 and 10,000.`);return}let s=[{fact:`transaction.direction`,operator:`equals`,value:`inbound`},{fact:r,operator:`contains`,value:a.trim()},{fact:`transaction.currency`,operator:`equals`,value:w}],l={project_id:t};T&&(s.push({fact:`transaction.amount`,operator:`equals`,value:T}),l.amount=T),await e.onCreateRule({project_id:t,priority:i,mode:f,enabled:!0,conditions:s,actions:[{action_type:`project_payment.route`,payload:l}]})&&(_(``),o(``),c(``),d(``),p(`suggest`),h(`0`))}async function oe(){if(!y)return;let{suggestion:t,decision:n}=y;(n===`approve`?await e.onApproveSuggestion(t):await e.onRejectSuggestion(t))&&b(null)}return(0,N.jsxs)(`div`,{className:`grid gap-5`,children:[(0,N.jsxs)(W,{children:[(0,N.jsxs)(G,{children:[(0,N.jsxs)(`div`,{children:[(0,N.jsx)(K,{children:`Project payment automation`}),(0,N.jsx)(`p`,{className:`mt-1 text-sm text-muted-foreground`,children:`Categorize canonical ERP bank receipts, review uncertain matches, and notify only registered private project channels.`})]}),(0,N.jsxs)(U,{id:`refreshPaymentAutomation`,type:`button`,variant:`outline`,onClick:e.onRefresh,disabled:e.loading.paymentAutomation,children:[(0,N.jsx)(ne,{}),`Refresh`]})]}),(0,N.jsxs)(Xt,{className:`grid gap-4`,children:[(0,N.jsx)(`div`,{className:`rounded-md border bg-secondary p-3 text-sm text-muted-foreground`,children:`An approval can create a low-priority learned suggestion when its canonical ERP evidence has a payer identity and currency. Learned rules always require review; only configured automatic rules with exact identity, currency, and amount may route without review.`}),(0,N.jsxs)(J,{className:`max-w-md`,children:[`Filter project`,(0,N.jsxs)(Y,{"aria-label":`Filter payment automation by project`,value:t,onChange:e=>n(e.target.value),children:[(0,N.jsx)(`option`,{value:``,children:`All active projects`}),e.projects.map(e=>(0,N.jsx)(`option`,{value:e.id,children:e.display_name},e.id))]})]})]})]}),(0,N.jsxs)(W,{children:[(0,N.jsxs)(G,{children:[(0,N.jsxs)(`div`,{children:[(0,N.jsx)(K,{children:`Create routing rule`}),(0,N.jsx)(`p`,{className:`mt-1 text-sm text-muted-foreground`,children:`Start with a suggestion. Automatic routing is intentionally stricter.`})]}),(0,N.jsx)(H,{variant:f===`automatic`?`running`:`neutral`,children:f===`automatic`?`Automatic needs exact proof`:`Human review first`})]}),(0,N.jsx)(Xt,{children:(0,N.jsxs)(`form`,{className:`grid gap-4`,onSubmit:e=>void ae(e),children:[(0,N.jsxs)(`div`,{className:`grid gap-4 md:grid-cols-2 xl:grid-cols-3`,children:[(0,N.jsxs)(J,{children:[`Project`,(0,N.jsxs)(Y,{"aria-label":`Rule project`,value:t,onChange:e=>n(e.target.value),disabled:!e.canWrite,children:[(0,N.jsx)(`option`,{value:``,children:`Choose an active project`}),e.projects.map(e=>(0,N.jsx)(`option`,{value:e.id,children:e.display_name},e.id))]})]}),(0,N.jsxs)(J,{children:[`Rule mode`,(0,N.jsxs)(Y,{"aria-label":`Rule mode`,value:f,onChange:e=>p(e.target.value),disabled:!e.canWrite,children:[(0,N.jsx)(`option`,{value:`suggest`,children:`Suggest for review`}),(0,N.jsx)(`option`,{value:`automatic`,children:`Route automatically`})]})]}),(0,N.jsxs)(J,{children:[`Priority`,(0,N.jsx)(q,{"aria-label":`Rule priority`,inputMode:`numeric`,value:m,onChange:e=>h(e.target.value),disabled:!e.canWrite})]}),(0,N.jsxs)(J,{children:[`Identity field`,(0,N.jsx)(Y,{"aria-label":`Rule identity field`,value:r,onChange:e=>i(e.target.value),disabled:!e.canWrite,children:Dn.map(e=>(0,N.jsx)(`option`,{value:e.value,children:e.label},e.value))})]}),(0,N.jsxs)(J,{children:[`Identity contains`,(0,N.jsx)(q,{"aria-label":`Rule identity value`,value:a,onChange:e=>o(e.target.value),placeholder:`e.g. Acme Ltd`,disabled:!e.canWrite})]}),(0,N.jsxs)(J,{children:[`Currency`,(0,N.jsx)(q,{"aria-label":`Rule currency`,value:s,onChange:e=>c(e.target.value),placeholder:`GBP`,maxLength:12,disabled:!e.canWrite})]}),(0,N.jsxs)(J,{children:[`Exact amount `,f===`automatic`?`(required)`:`(optional)`,(0,N.jsx)(q,{"aria-label":`Rule exact amount`,inputMode:`decimal`,value:u,onChange:e=>d(e.target.value),placeholder:`1250.00`,disabled:!e.canWrite})]})]}),g?(0,N.jsx)(`p`,{className:`text-sm text-destructive`,children:g}):null,e.canWrite?null:(0,N.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`You can inspect rules and suggestions, but configuration write permission is required to change routing.`}),(0,N.jsx)(`div`,{children:(0,N.jsxs)(U,{id:`createPaymentRule`,type:`submit`,disabled:!re||e.loading.paymentRuleCreate,children:[(0,N.jsx)(te,{}),`Create rule`]})})]})})]}),(0,N.jsxs)(W,{children:[(0,N.jsxs)(G,{children:[(0,N.jsxs)(`div`,{children:[(0,N.jsx)(K,{children:`Configured rules`}),(0,N.jsx)(`p`,{className:`mt-1 text-sm text-muted-foreground`,children:`Disabling a rule retains its event and decision history.`})]}),(0,N.jsxs)(H,{variant:`neutral`,children:[S.length,` shown`]})]}),(0,N.jsx)(pe,{hidden:S.length!==0,children:`No payment rules match this filter.`}),(0,N.jsx)(`div`,{className:Vt(`overflow-x-auto`,S.length===0&&`hidden`),children:(0,N.jsxs)(Ht,{"aria-label":`Configured payment routing rules`,className:`min-w-[900px]`,children:[(0,N.jsx)(Ut,{children:(0,N.jsxs)(Gt,{children:[(0,N.jsx)(L,{children:`Project`}),(0,N.jsx)(L,{children:`Mode`}),(0,N.jsx)(L,{children:`Match conditions`}),(0,N.jsx)(L,{children:`Version`}),(0,N.jsx)(L,{children:`Created by`}),(0,N.jsx)(L,{className:`text-right`,children:`Action`})]})}),(0,N.jsx)(Wt,{children:S.map(t=>(0,N.jsxs)(Gt,{children:[(0,N.jsxs)(R,{children:[(0,N.jsx)(`div`,{className:`font-semibold`,children:ie(t.project_id)}),(0,N.jsx)(`div`,{className:`font-mono text-xs text-muted-foreground`,children:t.id})]}),(0,N.jsx)(R,{children:(0,N.jsxs)(`div`,{className:`flex flex-wrap gap-1`,children:[(0,N.jsx)(H,{variant:t.mode===`automatic`?`running`:`neutral`,children:t.mode}),(0,N.jsx)(H,{variant:t.enabled?`succeeded`:`failed`,children:t.enabled?`enabled`:`disabled`}),(0,N.jsx)(H,{variant:t.origin===`learned`?`queued`:`neutral`,children:t.origin===`learned`?`learned`:`configured`})]})}),(0,N.jsx)(R,{className:`max-w-[360px]`,children:(0,N.jsx)(`div`,{className:`flex flex-wrap gap-1`,children:t.conditions.map(e=>(0,N.jsx)(H,{variant:`neutral`,children:On(e)},On(e)))})}),(0,N.jsxs)(R,{children:[`v`,t.version]}),(0,N.jsx)(R,{className:`max-w-48 truncate text-muted-foreground`,children:t.created_by||`Unknown`}),(0,N.jsx)(R,{className:`text-right`,children:t.enabled&&e.canWrite?(0,N.jsxs)(U,{type:`button`,variant:`outline`,size:`sm`,onClick:()=>void e.onDisableRule(t),disabled:e.loading[`paymentRule:${t.id}`],children:[(0,N.jsx)(k,{}),`Disable`]}):null})]},t.id))})]})})]}),(0,N.jsxs)(W,{children:[(0,N.jsxs)(G,{children:[(0,N.jsxs)(`div`,{children:[(0,N.jsx)(K,{children:`Payment suggestions`}),(0,N.jsx)(`p`,{className:`mt-1 text-sm text-muted-foreground`,children:`Review the canonical ERP receipt before authorizing a project allocation.`})]}),(0,N.jsxs)(H,{variant:`neutral`,children:[C.length,` awaiting review`]})]}),(0,N.jsx)(pe,{hidden:C.length!==0,children:`No payment suggestions are waiting for review.`}),(0,N.jsxs)(Xt,{className:Vt(`grid gap-3`,C.length===0&&`hidden`),children:[C.map(t=>{let n=t.subject_snapshot||{},r=kn(n,`counterparty`),i=kn(n,`description`),a=kn(n,`reference_number`);return(0,N.jsxs)(`article`,{className:`grid gap-3 rounded-md border bg-background p-4`,children:[(0,N.jsxs)(`div`,{className:`flex flex-wrap items-start justify-between gap-2`,children:[(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`h3`,{className:`font-extrabold`,children:ie(t.project_id)}),(0,N.jsxs)(`p`,{className:`text-sm text-muted-foreground`,children:[An(n),` ·`,` `,r||i||`Unidentified payer`]})]}),(0,N.jsxs)(`div`,{className:`flex flex-wrap gap-1`,children:[(0,N.jsx)(H,{variant:`neutral`,children:t.mode}),(0,N.jsx)(H,{variant:`neutral`,children:t.status.replaceAll(`_`,` `)})]})]}),(0,N.jsxs)(`dl`,{className:`grid gap-2 text-sm md:grid-cols-3`,children:[(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`dt`,{className:`font-bold text-muted-foreground`,children:`Description`}),(0,N.jsx)(`dd`,{children:i||`—`})]}),(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`dt`,{className:`font-bold text-muted-foreground`,children:`Reference`}),(0,N.jsx)(`dd`,{children:a||`—`})]}),(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`dt`,{className:`font-bold text-muted-foreground`,children:`ERP transaction`}),(0,N.jsx)(`dd`,{className:`font-mono text-xs`,children:t.subject_id||`—`})]})]}),e.canWrite?y?.suggestion.id===t.id?(0,N.jsxs)(`div`,{className:`grid gap-2 rounded-md border border-amber-400/40 bg-amber-500/10 p-3 text-sm`,children:[(0,N.jsx)(`p`,{children:y.decision===`approve`?`Approve this project allocation and queue the channel notification?`:`Reject this project allocation suggestion?`}),(0,N.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,N.jsxs)(U,{type:`button`,onClick:()=>void oe(),disabled:e.loading[`paymentSuggestion:${t.id}`],children:[(0,N.jsx)(v,{}),`Confirm `,y.decision]}),(0,N.jsx)(U,{type:`button`,variant:`outline`,onClick:()=>b(null),disabled:e.loading[`paymentSuggestion:${t.id}`],children:`Cancel`})]})]}):(0,N.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,N.jsxs)(U,{type:`button`,onClick:()=>b({suggestion:t,decision:`approve`}),disabled:e.loading[`paymentSuggestion:${t.id}`],children:[(0,N.jsx)(v,{}),`Approve allocation`]}),(0,N.jsxs)(U,{type:`button`,variant:`outline`,onClick:()=>b({suggestion:t,decision:`reject`}),disabled:e.loading[`paymentSuggestion:${t.id}`],children:[(0,N.jsx)(k,{}),`Reject`]})]}):null]},t.id)}),(0,N.jsxs)(`div`,{className:`flex items-start gap-2 rounded-md border border-dashed p-3 text-sm text-muted-foreground`,children:[(0,N.jsx)(ee,{className:`mt-0.5 size-4 shrink-0`}),`Approval and rejection decisions are retained with the source evidence. An eligible approval may teach a future suggestion, but it never silently turns into automatic payment routing.`]})]})]})]})}var Mn=2e3,Nn=90,Pn={people:`/dashboard/people`,gigs:`/dashboard/gigs`,projects:`/dashboard/projects`,onboarding:`/dashboard/onboarding`,newsletter:`/dashboard/newsletter`,jobs:`/dashboard/jobs`,agent:`/dashboard/agent`,audit:`/dashboard/audit`,configuration:`/dashboard/configuration`,payments:`/dashboard/payments`},Fn={people:`people:read`,gigs:`gigs:read`,projects:`projects:read`,onboarding:`onboarding:read`,newsletter:`people:sync`,jobs:`jobs:read`,agent:`audit:read`,audit:`audit:read`,configuration:`configuration:read`,payments:`configuration:read`},In={discord:{label:`Discord`,options:[[`linked`,`Linked`],[`missing`,`Missing`]]},email_508:{label:`508 email`,options:[[`present`,`Present`],[`missing`,`Missing`]]},resume:{label:`Resume`,options:[[`present`,`Present`],[`missing`,`Missing`]]},skills:{label:`Skills`,options:[[`present`,`Parsed`],[`missing`,`Not parsed`]]},sync_status:{label:`Sync status`,options:[[`active`,`Active`],[`conflict`,`Conflict`],[`missing_in_crm`,`Missing in CRM`]]}},Ln=[[`pending`,`Needs review`],[`selected`,`Assigned to onboarder`],[`reachingout`,`Reaching out`],[`awaitingcontribution`,`Awaiting contribution`],[`onboarded`,`Onboarded`],[`waitlist`,`Waitlist`],[`rejected`,`Rejected`]],Rn=Ln.slice(0,4),zn=new Set([`onboarded`,`waitlist`,`rejected`]);function Bn(e){return String(e||``).trim().toLowerCase().replace(/[-_\s]+/g,``)}var Vn=class extends Error{status;statusText;payload;url;method;constructor(e,t,n,r,i,a){super(e),this.name=`ApiRequestError`,this.status=t,this.statusText=n,this.payload=r,this.url=i,this.method=a}};function Hn(e,t){let n=e.detail;if(typeof n==`string`&&n.trim())return n;let r=e.error;return typeof r==`string`?r===`person_not_found`?`No CRM person, ERPNext user, or ERPNext supplier matched "${typeof e.person==`string`&&e.person.trim()?e.person:`that person`}". Try an email address or an exact name from CRM/ERPNext.`:r===`candidate_not_found`?`The selected person record is no longer available. Search again and choose one of the current matches.`:r===`invalid_crm_profile`?`Paste a valid CRM Contact profile URL or Contact id.`:r===`crm_profile_not_found`?`That CRM Contact profile was not found.`:r===`crm_profile_mismatch`?`CRM returned a different Contact than the profile requested. Check the profile URL and try again.`:r===`crm_profile_lookup_failed`?`CRM profile lookup failed. Try again after CRM is reachable.`:r===`ambiguous_person`?`Multiple people matched. Choose the matching person record.`:r||t:t}function Un(e,t){return typeof e==`string`&&e.trim()?e:e instanceof Error&&e.message.trim()?e.message:t}function Wn(){return window.location.pathname.split(`/`).filter(Boolean)[1]||``}function Gn(){let e=Wn();return Object.hasOwn(Pn,e)?e:`people`}function Kn(e=`gigs`){let[,t,n]=window.location.pathname.split(`/`).filter(Boolean);if(t!==e||!n)return``;try{return decodeURIComponent(n)}catch{return``}}function qn(e=window.location.hash){return e.replace(/^#/,``)===`leads`?`leads`:`gigs`}function Jn(e){let t=e===`leads`?`#leads`:`#gigs`;window.history.replaceState(window.history.state,``,`${window.location.pathname}${window.location.search}${t}`)}async function X(e,t={}){let n=String(t.method||`GET`).toUpperCase(),r=new Headers(t.headers);r.set(`Accept`,`application/json`);let i;try{i=await fetch(e,{credentials:`same-origin`,...t,headers:r})}catch(t){throw new Vn(Un(t,`Network request failed`),0,`Network request failed`,null,e,n)}if(i.status===401){let t=`${window.location.pathname}${window.location.search}`||`/dashboard`;throw window.location.assign(`/auth/login?next=${encodeURIComponent(t)}`),new Vn(`Session expired`,i.status,i.statusText,null,e,n)}if(!i.ok){let t=i.statusText,r=null;try{r=await i.json(),r&&typeof r==`object`&&(t=Hn(r,String(t||`Request failed`)))}catch{t=i.statusText}throw new Vn(typeof t==`string`?t:JSON.stringify(t),i.status,i.statusText,r,e,n)}return i.json()}function Yn(e,t,n){if(e===`gigs`){let e=t;if(n===`title`)return e.title||``;if(n===`status`)return e.status||``;if(n===`applications`)return Number(e.application_count||0);if(n===`activity`)return sr(e)}if(e===`projects`){let e=t;if(n===`display_name`)return e.display_name||``;if(n===`customer`)return e.customer||``;if(n===`status`)return e.source_status||``;if(n===`roster_count`)return Number(e.roster_count||0);if(n===`modified`)return e.source_modified_at||e.last_synced_at||``}if(e===`onboarding`){let e=t,r=e.profile_status||{};if(n===`name`)return e.name||e.email_508||e.email||``;if(n===`onboarding_state`){let t=rn(e);return t.toLowerCase()===`pending`?`zzz-${t}`:t}if(n===`onboarder`)return e.onboarder||``;if(n===`updated`)return e.onboarding_updated_at||``;if(n===`profile_gaps`)return[!r.discord_linked,!r.latest_resume,Number(r.skills_count||0)<=0].filter(Boolean).length}if(e===`people`){let e=t,r=e.profile_status||{};if(n===`name`)return e.name||e.email_508||e.email||``;if(n===`status`)return[r.crm_active,r.is_member,r.discord_linked,r.email_508,r.latest_resume].filter(Boolean).length;if(n===`discord`)return e.discord_username||e.discord_user_id||``;if(n===`resume`)return e.latest_resume_name||e.latest_resume_id||``}if(e===`audit`){let e=t;if(n===`actor`)return e.actor_display_name||e.actor_subject||e.actor_provider||``}if(e===`newsletter`){let e=t;if(n===`email`)return e.email||``;if(n===`source_provider`)return e.source_provider||``;if(n===`reason`)return e.reason||``;if(n===`first_seen_at`)return e.first_seen_at||``;if(n===`last_seen_at`)return e.last_seen_at||e.updated_at||``}return t[n]??``}function Xn(e,t,n){let r=n.direction===`asc`?1:-1;return[...t].sort((t,i)=>{let a=Yn(e,t,n.key),o=Yn(e,i,n.key);return typeof a==`number`&&typeof o==`number`?(a-o)*r:String(a).localeCompare(String(o),void 0,{numeric:!0})*r})}function Zn({label:e,value:t,id:n}){return(0,N.jsxs)(W,{className:`p-4`,children:[(0,N.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:e}),(0,N.jsx)(`strong`,{id:n,className:`block text-2xl`,children:t})]})}function Qn({value:e,query:t}){let n=t.trim().toLowerCase();if(!n)return(0,N.jsx)(N.Fragment,{children:e});let r=e.toLowerCase(),i=[],a=0,o=r.indexOf(n);for(;o>=0;){o>a&&i.push(e.slice(a,o));let t=o+n.length;i.push((0,N.jsx)(`mark`,{className:`rounded-sm bg-amber-200 px-0.5 text-inherit dark:bg-amber-500/35`,children:e.slice(o,t)},`${o}-${t}`)),a=t,o=r.indexOf(n,a)}return avoid 0);function Ut(e){return s.includes(e)}function Wt(e){return s.includes(`${e}:dry_run`)}function Gt(e){return Ut(e)||Wt(e)}function L(e){return Ut(Fn[e])}function R(){return Object.keys(Pn).find(e=>L(e))||`people`}function z(e,t){o({message:e,tone:t})}function B(e,t){z(Un(e,t),`error`)}function V(e,t){Ve(n=>({...n,[e]:t}))}function Kt(e,t=!1){let n=e;L(n)||(z(`${n[0].toUpperCase()}${n.slice(1)} requires SSO validation`,`error`),n=R()),n!==`gigs`&&j(``),n!==`projects`&&de(``),n===`gigs`&&t&&j(``),n===`projects`&&t&&de(``),n===`gigs`&&ct(t?`gigs`:qn()),i(n),t?window.history.pushState({view:n},``,Pn[n]):(!Object.hasOwn(Pn,Wn())||n!==e)&&window.history.replaceState({view:n},``,Pn[n])}Ht.current=Kt;function qt(e){ct(e),Jn(e)}function Jt(e){return!u||!e?``:`${u}/#Contact/view/${encodeURIComponent(e)}`}function H(e){return!u||!e?``:`${u}/api/v1/Attachment/file/${encodeURIComponent(e)}`}function Yt(e,t){qe(n=>{let r=n[e];return{...n,[e]:{key:t,direction:r.key===t&&r.direction===`asc`?`desc`:`asc`}}})}function W(e){j(e),ue(h.find(t=>t.id===e)||null),i(`gigs`),window.history.pushState({view:`gigs`,gigId:e},``,`/dashboard/gigs/${encodeURIComponent(e)}`)}function G(){j(``),ue(null),window.history.replaceState({view:`gigs`},``,Pn.gigs)}function K(e){de(e),i(`projects`),window.history.pushState({view:`projects`,projectId:e},``,`/dashboard/projects/${encodeURIComponent(e)}`)}function Xt(){de(``),window.history.replaceState({view:`projects`},``,Pn.projects)}async function q(){let e=await X(`/dashboard/api/me`);n(e);let t=Array.isArray(e.permissions)?e.permissions:[];return c(t),d((e.crm_base_url||``).replace(/\/+$/,``)),t}function J(){let e=new URLSearchParams({minutes:Je,limit:`100`});return Xe&&e.set(`status`,Xe),Qe.trim()&&e.set(`type`,Qe.trim()),`/dashboard/api/jobs?${e.toString()}`}function Y(e={}){let t=e.status??et,n=e.query??nt,r=new URLSearchParams({limit:String(at)});return t&&r.set(`status`,t),n.trim()&&r.set(`query`,n.trim()),it&&r.set(`include_historical`,`true`),`/dashboard/api/gigs?${r.toString()}`}function Zt(){let e=new URLSearchParams({limit:`50`});return lt&&e.set(`status`,lt),`/dashboard/api/gig-leads?${e.toString()}`}function Qt(e={}){let t=new URLSearchParams;e.includeAvailable&&t.set(`include_available`,`true`);let n=t.toString();return`/dashboard/api/job-channels${n?`?${n}`:``}`}function $t(){let e=new URLSearchParams({limit:`100`,status:pt});return dt.trim()&&e.set(`query`,dt.trim()),`/dashboard/api/projects?${e.toString()}`}async function en(){V(`jobs`,!0),z(`Loading background tasks`);try{let e=await X(J());p(e),z(`Loaded ${e.length} background task${e.length===1?``:`s`}`,`ok`)}catch(e){B(e,`Unable to load background tasks`)}finally{V(`jobs`,!1)}}async function nn(e={}){V(`gigs`,!0);try{let t=await X(Y(e));v(t),z(`Loaded ${t.length} gig${t.length===1?``:`s`}`,`ok`),Zn()}catch(e){B(e,`Unable to load gigs`)}finally{V(`gigs`,!1)}}async function rn(){V(`gigLeads`,!0);try{let e=await X(Zt());ee(e),z(`Loaded ${e.length} lead${e.length===1?``:`s`}`,`ok`)}catch(e){B(e,`Unable to load job leads`)}finally{V(`gigLeads`,!1)}}async function on(e={}){if(Ut(`gigs:read`)){V(`jobPostChannels`,!0);try{let t=await X(Qt(e));ne(t.channels||[]),e.includeAvailable&&re(t.available_channels||t.channels||[])}catch(e){B(e,`Unable to load job channels`)}finally{V(`jobPostChannels`,!1)}}}function sn(e){ne(e.channels||[]),re(e.available_channels||e.channels||[])}async function cn(e,t){V(`jobPostChannel:${e}`,!0);try{return sn(await X(`/dashboard/api/job-channels/${encodeURIComponent(e)}`,{method:`PUT`,headers:{"Content-Type":`application/json`},body:JSON.stringify({posting_type:t})})),z(`Saved job channel`,`ok`),!0}catch(e){return B(e,`Unable to save job channel`),!1}finally{V(`jobPostChannel:${e}`,!1)}}async function ln(e){V(`jobPostChannel:${e}`,!0);try{sn(await X(`/dashboard/api/job-channels/${encodeURIComponent(e)}`,{method:`DELETE`})),z(`Deregistered job channel`,`ok`)}catch(e){B(e,`Unable to deregister job channel`)}finally{V(`jobPostChannel:${e}`,!1)}}async function un(e){for(let t=0;twindow.setTimeout(e,Mn));let t=await X(`/dashboard/api/jobs/${encodeURIComponent(e)}`),n=String(t.status||``).trim().toLowerCase();if(tn(n)){if(n===`succeeded`){z(`HN lead scrape finished; refreshing leads`,`ok`),await rn(),r===`jobs`&&await en();return}r===`jobs`&&await en(),z(`HN lead scrape ${n||`finished`}; check background task ${e}`,`warning`);return}}z(`HN lead scrape is still running; refresh leads or check background task ${e}`,`warning`)}async function dn(){V(`projects`,!0);try{let e=await X($t());E(e.projects||[]),k(e.summary||{}),z(`Loaded ${(e.projects||[]).length} project${(e.projects||[]).length===1?``:`s`}`,`ok`)}catch(e){B(e,`Unable to load projects`)}finally{V(`projects`,!1)}}async function fn(){V(`syncProjects`,!0),z(`Queueing project sync`);try{let e=await X(`/dashboard/api/sync/projects`,{method:`POST`});e.dry_run?z(`Dry run only: would queue ${e.would_enqueue?.job_type||`project sync`}`,`warning`):z(`Queued project sync ${e.job_id}`,`ok`)}catch(e){B(e,`Unable to queue project sync`)}finally{V(`syncProjects`,!1)}}async function pn(e){let t=e.trim();if(t.length<2)return[];try{return(await X(`/dashboard/api/erpnext/customers?${new URLSearchParams({query:t}).toString()}`)).customers||[]}catch(e){return z(e instanceof Error?e.message:`Unable to search customers`,`error`),[]}}async function mn(e){let t=e.trim();if(t.length<2)return[];try{return(await X(`/dashboard/api/erpnext/contacts?${new URLSearchParams({query:t}).toString()}`)).contacts||[]}catch(e){return z(e instanceof Error?e.message:`Unable to search contacts`,`error`),[]}}async function hn(e){let t=e.trim();if(t.length<2)return[];try{return(await X(`/dashboard/api/erpnext/account-managers?${new URLSearchParams({query:t}).toString()}`)).users||[]}catch(e){return z(e instanceof Error?e.message:`Unable to search account managers`,`error`),[]}}async function gn(){try{let e=(await X(`/dashboard/api/erpnext/cost-centers`)).cost_centers||[];return e.length?e:[{name:`Projects - 5`,cost_center_name:`Projects`}]}catch(e){return z(e instanceof Error?e.message:`Unable to load cost centers`,`error`),[{name:`Projects - 5`,cost_center_name:`Projects`}]}}async function _n(e){V(`createProject`,!0);try{let t=await X(`/dashboard/api/projects/create`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify(e)});return t.project.id?(E(e=>e.some(e=>e.id===t.project.id)?e.map(e=>e.id===t.project.id?t.project:e):[t.project,...e]),z(t.setup_warnings?.length?t.setup_warning_message||`Created ERP project setup; account manager setup needs follow-up`:`Created ERP project setup`,t.setup_warnings?.length?`warning`:`ok`),K(t.project.id)):(z([t.cache_refresh_message||`Created ERP project in ERPNext; local sync is pending`,t.setup_warnings?.length?t.setup_warning_message||`Account manager setup needs follow-up`:``].filter(Boolean).join(` `),t.setup_warnings?.length?`warning`:`ok`),dn()),!0}catch(e){return z(e instanceof Error?e.message:`Unable to create project`,`error`),!1}finally{V(`createProject`,!1)}}async function vn(e,t){V(`project:${e}:status`,!0);try{let n=await X(`/dashboard/api/projects/${encodeURIComponent(e)}/status`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:t})});E(t=>t.map(t=>t.id===e?n.project:t)),z(`Updated project status`,`ok`)}catch(e){B(e,`Unable to update project`)}finally{V(`project:${e}:status`,!1)}}async function yn(e,t){if(e.length===0)return!1;V(`projectsBulkUpdate`,!0);try{let n=await X(`/dashboard/api/projects/bulk`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({project_ids:e,...t})}),r=n.projects||[];E(e=>e.map(e=>r.find(t=>t.id===e.id)||e));let i=n.failures||[];return z(i.length?`Updated ${r.length}; ${i.length} failed`:`Updated ${r.length} project${r.length===1?``:`s`}`,i.length?`error`:`ok`),i.length===0}catch(e){return B(e,`Unable to bulk update projects`),!1}finally{V(`projectsBulkUpdate`,!1)}}async function bn(e,t,n,r){let i=t.trim(),a=n.trim();if(!i||!a)return!1;V(`project:${e}:user`,!0);try{let t=await X(`/dashboard/api/projects/${encodeURIComponent(e)}/users`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({user:i,candidate_id:a,...r||{}})});return E(n=>n.map(n=>n.id===e?t.project:n)),z(t.activity_cost_error?`Added project user; rate failed`:t.activity_cost?`Added project user and rate`:`Added project user`,t.activity_cost_error?`error`:`ok`),!0}catch(e){return B(e,`Unable to add project user`),!1}finally{V(`project:${e}:user`,!1)}}async function xn(e,t){let n=t.trim();if(!n)return!1;V(`project:${e}:user`,!0);try{let t=await X(`/dashboard/api/projects/${encodeURIComponent(e)}/users/remove`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({user:n})});return E(n=>n.map(n=>n.id===e?t.project:n)),z(`Removed project user`,`ok`),!0}catch(e){return z(e instanceof Error?e.message:`Unable to remove project user`,`error`),!1}finally{V(`project:${e}:user`,!1)}}async function wn(e,t,n){let r=t.trim();if(!r)return!1;V(`project:${e}:historical`,!0);try{let t=await X(`/dashboard/api/projects/${encodeURIComponent(e)}/historical-members`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({person:r,candidate_id:n})});return E(n=>n.map(n=>n.id===e?t.project:n)),Ge(null),z(`Added historical project member`,`ok`),!0}catch(t){if(t instanceof Vn&&t.status===409){let n=t.payload?.candidates||[];if(n.length>0)return Ge({projectId:e,person:r,candidates:n}),z(`Choose the matching person record`,`error`),!1}return B(t,`Unable to add historical member`),!1}finally{V(`project:${e}:historical`,!1)}}async function Dn(e,t){let n=t.trim();if(!n)return!1;V(`project:${e}:historical`,!0);try{let t=await X(`/dashboard/api/projects/${encodeURIComponent(e)}/historical-members/remove`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({source_user_id:n})});return E(n=>n.map(n=>n.id===e?t.project:n)),z(`Removed historical project member`,`ok`),!0}catch(e){return z(e instanceof Error?e.message:`Unable to remove historical member`,`error`),!1}finally{V(`project:${e}:historical`,!1)}}async function On(e,t,n){V(`project:${e}:wiki`,!0);try{await X(`/dashboard/api/projects/${encodeURIComponent(e)}/wiki-match`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:t,row_key:n})}),z(t===`no_row`?`Marked as no wiki row`:`Confirmed wiki match`,`ok`),await kn()}catch(e){B(e,`Unable to save wiki match`)}finally{V(`project:${e}:wiki`,!1)}}async function kn(){V(`wikiMatches`,!0);try{ce(await X(`/dashboard/api/projects/wiki-matches`)),z(`Loaded wiki match preview`,`ok`)}catch(e){B(e,`Unable to load wiki matches`)}finally{V(`wikiMatches`,!1)}}async function An(e){V(`gig:${e}:detail`,!0);try{ue(await X(`/dashboard/api/gigs/${encodeURIComponent(e)}`))}catch(e){ue(null),B(e,`Unable to load gig`)}finally{V(`gig:${e}:detail`,!1)}}async function Ln(){await nn(),await rn(),await on(),A&&await An(A)}async function Rn(){V(`gigLeadsSync`,!0),z(`Queueing HN lead scrape`);try{let e=await X(`/dashboard/api/gig-leads/sync`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({source:`hackernews_who_is_hiring`})});if(z(e.created===!1?`HN lead scrape already queued`:`HN lead scrape queued`,`ok`),e.job_id)try{await un(e.job_id)}catch(e){B(e,`Unable to monitor HN lead scrape`)}else await rn()}catch(e){B(e,`Unable to queue HN lead scrape`)}finally{V(`gigLeadsSync`,!1)}}async function Hn(e,t){V(`gigLead:${e}:review`,!0);try{await X(`/dashboard/api/gig-leads/${encodeURIComponent(e)}/review`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:t})}),z(`${t===`approved`?`Approved`:`Rejected`} lead`,`ok`),await rn()}catch(e){B(e,`Unable to review lead`)}finally{V(`gigLead:${e}:review`,!1)}}async function Yn(e,t={}){V(`gigLead:${e}:post`,!0);try{await X(`/dashboard/api/gig-leads/${encodeURIComponent(e)}/post`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({channel_id:t.channelId||void 0,engagement_status:t.engagementStatus||`lead`,tags:(t.tags||[]).join(`,`)||void 0})}),z(`Posted lead to Discord`,`ok`),qt(`gigs`),tt(``),rt(``),await rn(),await nn({status:``,query:``})}catch(e){B(e,`Unable to post lead`)}finally{V(`gigLead:${e}:post`,!1)}}async function Zn(){if(Ut(`gigs:read`)){V(`notifications`,!0);try{let e=await X(`/dashboard/api/notifications?limit=20`);F(e.stale_days||7),_t(e.contacted_reminder_days||5),pe(e.notifications||[])}catch(e){B(e,`Unable to load notifications`)}finally{V(`notifications`,!1)}}}async function Qn(e,t){V(`gig:${e}:status`,!0);try{let n=(await X(`/dashboard/api/gigs/${encodeURIComponent(e)}/status`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:t})})).discord_title_sync?.status;z(n===`error`?`Updated gig status; Discord title sync failed`:`Updated gig status`,n===`error`?`error`:`ok`),await nn(),A===e&&await An(e)}catch(e){B(e,`Unable to update gig`)}finally{V(`gig:${e}:status`,!1)}}async function $n(e,t,n){V(`application:${t}:status`,!0);try{await X(`/dashboard/api/gigs/${encodeURIComponent(e)}/applications/${encodeURIComponent(t)}/status`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:n})}),z(`Updated candidate status`,`ok`),await nn(),A===e&&await An(e)}catch(e){B(e,`Unable to update candidate`)}finally{V(`application:${t}:status`,!1)}}async function rr(e,t){let n=t.trim();if(!n)return z(`Choose a candidate first`,`warning`),!1;V(`gig:${e}:addCandidate`,!0);try{return await X(`/dashboard/api/gigs/${encodeURIComponent(e)}/applications`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({crm_profile:n})}),z(`Added candidate`,`ok`),await nn(),A===e&&await An(e),!0}catch(e){return B(e,`Unable to add candidate`),!1}finally{V(`gig:${e}:addCandidate`,!1)}}function ir(){let e=new URLSearchParams({limit:`25`});vt.trim()&&e.set(`query`,vt.trim()),bt&&e.set(`is_member`,bt);for(let[t,n]of Object.entries(St))n&&e.set(t,n);return`/dashboard/api/people?${e.toString()}`}async function ar(){V(`people`,!0);try{_e(await X(ir()))}catch(e){B(e,`Unable to load people`)}finally{V(`people`,!1)}}async function or(){V(`newsletterStatus`,!0);try{xe(await X(`/dashboard/api/newsletter/status`))}catch(e){B(e,`Unable to load newsletter sync status`)}finally{V(`newsletterStatus`,!1)}}async function sr(){V(`newsletterSuppressions`,!0);try{ye((await X(`/dashboard/api/newsletter/suppressions?limit=200`)).suppressions||[])}catch(e){B(e,`Unable to load newsletter suppressions`)}finally{V(`newsletterSuppressions`,!1)}}async function cr(){await Promise.all([or(),sr()])}function lr(){let e=new URLSearchParams({limit:`25`});kt.trim()&&e.set(`query`,kt.trim()),jt&&e.set(`onboarding_state`,jt),Nt.trim()&&e.set(`onboarder`,Nt.trim());for(let[t,n]of Object.entries(Ft))n&&e.set(t,n);return`/dashboard/api/onboarding?${e.toString()}`}async function ur(){V(`onboarding`,!0);try{Ce(await X(lr()))}catch(e){B(e,`Unable to load onboarding`)}finally{V(`onboarding`,!1)}}async function dr(e,t){if(!e)return z(`Missing CRM contact`,`error`),null;let n=`onboarding-email-draft:${e}`;V(n,!0);try{let n=await X(`/dashboard/api/onboarding/${encodeURIComponent(e)}/email/draft`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify(t)});return z(`Drafted onboarding email`,`ok`),n}catch(e){return B(e,`Unable to draft onboarding email`),null}finally{V(n,!1)}}async function fr(e,t,n){if(!e)return z(`Missing CRM contact`,`error`),null;let r=`onboarding-email-send:${e}`;V(r,!0);try{let r=await X(`/dashboard/api/onboarding/${encodeURIComponent(e)}/email/send`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({...t,markdown_body:n})});return Ce(t=>t.map(t=>t.crm_contact_id===e?{...t,onboarding_email_sent_at:r.onboarding_email_sent_at||t.onboarding_email_sent_at,onboarding_email_sent_by:r.onboarding_email_sent_by||t.onboarding_email_sent_by,onboarding_email_recipient:r.onboarding_email_recipient||r.recipient_email||t.onboarding_email_recipient}:t)),z(`Sent onboarding email`,`ok`),r}catch(e){return B(e,`Unable to send onboarding email`),null}finally{V(r,!1)}}async function mr(){V(`audit`,!0);try{Te(await X(`/dashboard/api/audit-events?limit=25`))}catch(e){B(e,`Unable to load audit events`)}finally{V(`audit`,!1)}}async function hr(){V(`agent`,!0);try{De(await X(`/dashboard/api/agent?limit=100`))}catch(e){B(e,`Unable to load agent report`)}finally{V(`agent`,!1)}}async function _r(){V(`configuration`,!0);try{ke((await X(`/dashboard/api/configuration`)).items)}catch(e){B(e,`Unable to load configuration`)}finally{V(`configuration`,!1)}}async function vr(){V(`paymentAutomation`,!0);try{let[e,t,n]=await Promise.all([X(`/dashboard/api/project-payment-rules`),X(`/dashboard/api/project-payment-suggestions?limit=100`),X(`/dashboard/api/projects?limit=100&status=Open`)]);Ne(e.rules||[]),Fe(t.suggestions||[]),je(n.projects||[])}catch(e){B(e,`Unable to load project payment automation`)}finally{V(`paymentAutomation`,!1)}}async function yr(e){V(`paymentRuleCreate`,!0);try{let t=await X(`/dashboard/api/project-payment-rules`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify(e)});return Ne(e=>[t.rule,...e]),z(`Created payment routing rule`,`ok`),!0}catch(e){return B(e,`Unable to create payment routing rule`),!1}finally{V(`paymentRuleCreate`,!1)}}async function br(e){V(`paymentRule:${e.id}`,!0);try{let t=await X(`/dashboard/api/project-payment-rules/${encodeURIComponent(e.id)}`,{method:`DELETE`,headers:{"Content-Type":`application/json`},body:JSON.stringify({expected_version:e.version})});return Ne(n=>n.map(n=>n.id===e.id?t.rule:n)),z(`Disabled payment routing rule`,`ok`),!0}catch(e){return B(e,`Unable to disable payment routing rule`),!1}finally{V(`paymentRule:${e.id}`,!1)}}async function xr(e,t){V(`paymentSuggestion:${e.id}`,!0);try{let n=await X(`/dashboard/api/project-payment-suggestions/${encodeURIComponent(e.id)}/${t}`,{method:`POST`,headers:{"Content-Type":`application/json`},body:`{}`});return Fe(t=>t.filter(t=>t.id!==e.id)),t===`approve`&&n.learning?.status===`created`&&vr(),z(t===`approve`?n.status===`approved_pending_recovery`?`Approved payment suggestion; delivery recovery is queued`:`Approved payment suggestion; routing is queued`:`Rejected payment suggestion`,t===`approve`&&n.status===`approved_pending_recovery`?`warning`:`ok`),!0}catch(e){return B(e,`Unable to ${t} payment suggestion`),!1}finally{V(`paymentSuggestion:${e.id}`,!1)}}async function Sr(e,t){V(`configuration:${e}`,!0);try{return ke((await X(`/dashboard/api/configuration/${encodeURIComponent(e)}`,{method:`PUT`,headers:{"Content-Type":`application/json`},body:JSON.stringify({value:t})})).items),z(`Saved ${e}`,`ok`),!0}catch(t){return B(t,`Unable to save ${e}`),!1}finally{V(`configuration:${e}`,!1)}}async function Cr(e){V(`configuration:${e}`,!0);try{ke((await X(`/dashboard/api/configuration/${encodeURIComponent(e)}`,{method:`PUT`,headers:{"Content-Type":`application/json`},body:JSON.stringify({clear:!0})})).items),z(`Cleared ${e}`,`ok`)}catch(t){B(t,`Unable to clear ${e}`)}finally{V(`configuration:${e}`,!1)}}async function wr(e){V(`detail:${e}`,!0),z(`Loading ${e}`);try{ze(await X(`/dashboard/api/jobs/${encodeURIComponent(e)}`)),z(`Loaded ${e}`,`ok`)}catch(e){B(e,`Unable to load task detail`)}finally{V(`detail:${e}`,!1)}}async function Tr(e){V(`rerun:${e}`,!0),z(`Rerunning ${e}`);try{let t=await X(`/dashboard/api/jobs/${encodeURIComponent(e)}/rerun`,{method:`POST`});t.dry_run?z(`Dry run only: would rerun ${t.would_enqueue?.job_type||e}`,`warning`):(z(`Queued rerun ${t.job_id}`,`ok`),await en())}catch(e){B(e,`Unable to rerun task`)}finally{V(`rerun:${e}`,!1)}}async function Er(){V(`syncPeople`,!0),z(`Queueing people sync`);try{let e=await X(`/dashboard/api/sync/people`,{method:`POST`});e.dry_run?z(`Dry run only: would queue ${e.would_enqueue?.job_type||`people sync`}`,`warning`):z(`Queued people sync ${e.job_id}`,`ok`)}catch(e){B(e,`Unable to queue people sync`)}finally{V(`syncPeople`,!1)}}async function Dr(){V(`syncNewsletters`,!0),z(`Queueing newsletter sync`);try{let e=await X(`/dashboard/api/sync/newsletters`,{method:`POST`});if(e.dry_run){let t=Cn(e.preview);z(t?`Dry run only: ${t}`:`Dry run completed`,`warning`)}else z(`Queued newsletter sync ${e.job_id}`,`ok`);cr()}catch(e){B(e,`Unable to queue newsletter sync`)}finally{V(`syncNewsletters`,!1)}}async function Or(e,t){let n=String(e||``).trim(),r=t.trim();if(!n){z(`Missing CRM contact id`,`error`);return}if(!r){z(`Enter a 508 username`,`error`);return}V(`onboarder:${n}`,!0),z(`Assigning ${r}`);try{let e=await X(`/dashboard/api/onboarding/${encodeURIComponent(n)}/onboarder`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({onboarder:r})});Ce(t=>t.map(t=>t.crm_contact_id===e.contact_id?{...t,onboarder:e.onboarder,onboarding_state:e.state_updated&&e.onboarding_state?e.onboarding_state:t.onboarding_state,onboarding_status_label:e.onboarding_status_label||(e.state_updated?void 0:t.onboarding_status_label)}:t)),z(`Assigned ${e.onboarder}`,`ok`)}catch(e){B(e,`Unable to assign onboarder`)}finally{V(`onboarder:${n}`,!1)}}async function kr(e,t){let n=String(e||``).trim(),r=t.trim();if(!n){z(`Missing CRM contact id`,`error`);return}if(!r){z(`Choose an onboarding status`,`error`);return}V(`onboarding-status:${n}`,!0),z(`Updating onboarding status`);try{let e=await X(`/dashboard/api/onboarding/${encodeURIComponent(n)}/status`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:r})}),t=Bn(e.onboarding_state),i=e.onboarding_status_label||an(t);Ce(n=>n.map(n=>n.crm_contact_id===e.contact_id?{...n,onboarding_state:t,onboarding_status_label:i}:n).filter(n=>n.crm_contact_id!==e.contact_id||!zn.has(t))),z(`Status set to ${i}`,`ok`)}catch(e){B(e,`Unable to update onboarding status`)}finally{V(`onboarding-status:${n}`,!1)}}async function Ar(e){let t=e.email.trim().toLowerCase(),n=e.first_name.trim();if(!t?.endsWith(`@508.dev`))return z(`Enter the engineer's @508.dev email`,`error`),null;if(!n)return z(`Enter the engineer name`,`error`),null;V(`engineerSetup`,!0);try{let r=await X(`/dashboard/api/onboarding/engineers`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({...e,email:t,first_name:n})});return z(`Set up ${r.employee_name||r.user||t}`,`ok`),r}catch(e){if(e instanceof Vn&&e.status===409){let t=e.payload&&typeof e.payload==`object`?e.payload:null,n=(Array.isArray(t?.matches)?t.matches:[]).map(e=>e?.label||e?.email).filter(Boolean).slice(0,2).join(`, `);z(n?`Similar account exists: ${n}`:`Similar account exists; confirm before creating`,`error`)}else z(e instanceof Error?e.message:`Unable to set up engineer`,`error`);return null}finally{V(`engineerSetup`,!1)}}async function Nr(){V(`logout`,!0);try{let e=await X(`/auth/logout`,{method:`POST`});window.location.assign(e.end_session_url||`/dashboard`)}catch(e){B(e,`Unable to log out`),V(`logout`,!1)}}(0,l.useEffect)(()=>{q().then(e=>{let t=Gn(),n=e.includes(Fn[t])?t:Object.keys(Pn).find(t=>e.includes(Fn[t]))||`people`;j(n===`gigs`?Kn():``),de(n===`projects`?Kn(`projects`):``),i(n),(!Object.hasOwn(Pn,Wn())||n!==t)&&window.history.replaceState({view:n},``,Pn[n])}).catch(e=>{B(e,`Dashboard failed to load`)})},[]),(0,l.useEffect)(()=>{let e=()=>{j(Kn()),de(Kn(`projects`)),Ht.current(Gn(),!1)};return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)},[]),(0,l.useEffect)(()=>{let e=()=>ct(qn());return window.addEventListener(`hashchange`,e),()=>window.removeEventListener(`hashchange`,e)},[]),(0,l.useEffect)(()=>{if(!a.message)return;let e=window.setTimeout(()=>o({message:``}),4500);return()=>window.clearTimeout(e)},[a.message]),(0,l.useEffect)(()=>{},[]),(0,l.useEffect)(()=>{s.length!==0&&(Ut(`gigs:read`)&&Zn(),r===`people`&&ar(),r===`gigs`&&(nn(),rn(),on()),r===`projects`&&dn(),r===`onboarding`&&ur(),r===`newsletter`&&cr(),r===`jobs`&&en(),r===`agent`&&hr(),r===`audit`&&mr(),r===`payments`&&vr(),r===`configuration`&&(_r(),on({includeAvailable:!0})))},[r]),(0,l.useEffect)(()=>{s.length!==0&&(Ut(`gigs:read`)&&Zn(),r===`people`&&ar(),r===`gigs`&&(nn(),rn(),on()),r===`projects`&&dn(),r===`onboarding`&&ur(),r===`newsletter`&&cr(),r===`jobs`&&en(),r===`agent`&&hr(),r===`audit`&&mr(),r===`payments`&&vr(),r===`configuration`&&(_r(),on({includeAvailable:!0})))},[s]),(0,l.useEffect)(()=>{r===`jobs`&&s.length>0&&en()},[Je,Xe]),(0,l.useEffect)(()=>{r===`gigs`&&s.length>0&&nn()},[et,it,at]),(0,l.useEffect)(()=>{r===`gigs`&&s.length>0&&rn()},[lt]),(0,l.useEffect)(()=>{r===`projects`&&s.length>0&&dn()},[pt]),(0,l.useEffect)(()=>{r===`gigs`&&A&&s.length>0&&An(A)},[r,A,s]),(0,l.useEffect)(()=>{r===`people`&&s.length>0&&ar()},[bt]),(0,l.useEffect)(()=>{r===`people`&&s.length>0&&ar()},[St]),(0,l.useEffect)(()=>{r===`onboarding`&&s.length>0&&ur()},[jt]),(0,l.useEffect)(()=>{r===`onboarding`&&s.length>0&&ur()},[Ft]);let Pr=(0,l.useMemo)(()=>Xn(`jobs`,f,Ke.jobs),[f,Ke.jobs]),Fr=(0,l.useMemo)(()=>Xn(`people`,ge,Ke.people),[ge,Ke.people]),Ir=(0,l.useMemo)(()=>Xn(`onboarding`,Se,Ke.onboarding),[Se,Ke.onboarding]),Lr=(0,l.useMemo)(()=>Xn(`gigs`,h,Ke.gigs),[h,Ke.gigs]),Rr=(0,l.useMemo)(()=>Xn(`projects`,oe,Ke.projects),[oe,Ke.projects]),zr=(0,l.useMemo)(()=>{let e=new Set;for(let t of ve)t.source_provider&&e.add(t.source_provider);for(let[t]of Tn(be?.latest_job))e.add(t);return[...e].sort((e,t)=>e.localeCompare(t))},[ve,be]),Br=(0,l.useMemo)(()=>Xn(`newsletter`,Dt?ve.filter(e=>e.source_provider===Dt):ve,Ke.newsletter),[Dt,ve,Ke.newsletter]),Vr=(0,l.useMemo)(()=>le?.id===A?le:Lr.find(e=>e.id===A)||null,[le,A,Lr]),Hr=(0,l.useMemo)(()=>Rr.find(e=>e.id===M)||null,[M,Rr]),Ur=(0,l.useMemo)(()=>Xn(`audit`,we,Ke.audit),[we,Ke.audit]),qr=(0,l.useMemo)(()=>f.reduce((e,t)=>(e[t.status]=(e[t.status]||0)+1,e),{}),[f]),Jr=Object.keys(In).filter(e=>!St[e]),Yr=Object.keys(In).filter(e=>e!==`sync_status`&&e!==`email_508`&&!Ft[e]);function Xr(e){if(e.type===`stale_recruiting_gig`||e.type===`contacted_gig`){let t=e.type===`contacted_gig`,n=t?`contacted-gig:`:`stale-recruiting:`,r=e.engagement_id||(e.id.startsWith(n)?e.id.slice(n.length):``);r?W(r):(tt(t?`contacted`:`recruiting`),Kt(`gigs`,!0))}he(!1)}(0,l.useEffect)(()=>{!Jr.includes(Ct)&&Jr[0]&&wt(Jr[0])},[Jr,Ct]),(0,l.useEffect)(()=>{let e=In[Ct]?.options;e?.[0]&&!e.some(([e])=>e===Tt)&&Et(e[0][0])},[Ct,Tt]),(0,l.useEffect)(()=>{!Yr.includes(Lt)&&Yr[0]&&Rt(Yr[0])},[Yr,Lt]),(0,l.useEffect)(()=>{let e=In[Lt]?.options;e?.[0]&&!e.some(([e])=>e===zt)&&Bt(e[0][0])},[Lt,zt]);let Zr=[t?.email,t?.crm_contact_id?`CRM ${t.crm_contact_id}`:``,t?.actor_provider].filter(Boolean).join(` | `);return(0,N.jsxs)(N.Fragment,{children:[(0,N.jsx)(`header`,{className:`sticky top-0 z-20 border-b bg-background/90 backdrop-blur`,children:(0,N.jsxs)(`div`,{className:`mx-auto flex max-w-7xl flex-col gap-4 px-5 py-4 md:flex-row md:items-center md:justify-between`,children:[(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`h1`,{className:`text-xl font-bold`,children:`508 Operations Dashboard`}),(0,N.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Operations view for authenticated 508 operators.`})]}),(0,N.jsxs)(`div`,{className:`flex min-w-0 items-center gap-3`,children:[Ut(`gigs:read`)?(0,N.jsx)(`div`,{className:`relative`,children:(0,N.jsxs)(U,{id:`notifications`,type:`button`,variant:`outline`,size:`icon`,"aria-label":`Notifications`,"aria-expanded":me,onClick:()=>he(e=>!e),children:[(0,N.jsx)(g,{}),fe.length>0?(0,N.jsx)(`span`,{className:`absolute -right-1 -top-1 grid min-h-5 min-w-5 place-items-center rounded-full bg-red-500 px-1 text-[11px] font-bold text-white`,children:fe.length}):null]})}):null,(0,N.jsxs)(`div`,{className:`grid min-w-0 gap-0.5 text-right text-sm text-muted-foreground`,children:[(0,N.jsx)(`strong`,{id:`userName`,className:`truncate text-foreground`,children:t?.display_name||t?.email||t?.subject||`Loading user`}),(0,N.jsx)(`span`,{id:`userMeta`,className:`truncate`,children:Zr||`Checking session`})]}),(0,N.jsxs)(U,{id:`logout`,type:`button`,variant:`outline`,onClick:Nr,disabled:Be.logout,children:[(0,N.jsx)(C,{}),`Log out`]})]})]})}),(0,N.jsx)(er,{open:me,notifications:fe,loading:Be.notifications,onClose:()=>he(!1),onRefresh:Zn,onOpenNotification:Xr}),(0,N.jsx)(nr,{toast:a}),null,(0,N.jsx)(tr,{choice:We,loading:!!(We&&Be[`project:${We.projectId}:historical`]),crmContactUrl:Jt,onClose:()=>Ge(null),onChoose:e=>{We&&wn(We.projectId,We.person,e)}}),(0,N.jsxs)(`main`,{className:`mx-auto grid max-w-7xl grid-cols-1 gap-5 px-5 py-5 md:grid-cols-[190px_minmax(0,1fr)]`,children:[(0,N.jsx)(`nav`,{className:`grid content-start gap-1 md:sticky md:top-24`,"aria-label":`Dashboard sections`,children:[[`people`,`People`,O],[`gigs`,`Gigs`,_],[`projects`,`Projects`,S],[`payments`,`Payments`,g],[`onboarding`,`Onboarding`,y],[`newsletter`,`Newsletter`,w],[`jobs`,`Background tasks`,m],[`agent`,`Agent`,ae],[`audit`,`Audit`,x],[`configuration`,`Configuration`,ie]].filter(([e])=>L(e)).map(([e,t,n])=>(0,N.jsxs)(`a`,{className:Vt(`flex min-h-10 items-center gap-2 rounded-md border border-transparent px-3 text-sm font-extrabold text-muted-foreground hover:border-border hover:bg-secondary hover:text-foreground`,r===e&&`border-primary bg-accent text-accent-foreground`),"data-view-link":e,"data-permission":Fn[e],href:Pn[e],"aria-current":r===e?`page`:void 0,onClick:t=>{t.preventDefault(),Kt(e,!0)},children:[(0,N.jsx)(n,{className:`size-4`}),t]},e))}),(0,N.jsxs)(`div`,{className:`grid min-w-0 gap-5`,children:[r===`people`?(0,N.jsx)(jr,{crmBaseUrl:u,people:Fr,sort:Ke.people,canSync:Gt(`people:sync`),loading:Be,peopleQuery:vt,peopleMember:bt,peopleFilters:St,peopleFilterKind:Ct,peopleFilterValue:Tt,peopleFilterKeys:Jr,onSearch:ar,onSync:Er,onSort:e=>Yt(`people`,e),setPeopleQuery:yt,setPeopleMember:xt,setPeopleFilterKind:wt,setPeopleFilterValue:Et,addFilter:()=>{I(e=>({...e,[Ct]:Tt}))},removeFilter:e=>{I(t=>{let n={...t};return delete n[e],n})},crmContactUrl:Jt,crmAttachmentUrl:H}):null,r===`newsletter`?(0,N.jsx)(En,{status:be,suppressions:Br,providerOptions:zr,providerFilter:Dt,sort:Ke.newsletter,loading:Be,canSync:Gt(`people:sync`),onRefresh:cr,onSync:Dr,onProviderFilterChange:Ot,onSort:e=>Yt(`newsletter`,e)}):null,r===`gigs`?(0,N.jsx)(gr,{gigs:Lr,leads:b,jobPostChannels:te,selectedGig:Vr,selectedGigId:A,sort:Ke.gigs,loading:Be,activeTab:st,status:et,query:nt,leadStatus:lt,includeHistorical:it,limit:at,staleDays:ht,contactedReminderDays:gt,canWrite:Ut(`gigs:write`),canSearchCandidates:Ut(`people:read`),canManageLeads:Ut(`people:read`),canIncludeHistorical:Ut(`people:read`),crmContactUrl:Jt,crmAttachmentUrl:H,setActiveTab:qt,setStatus:tt,setQuery:rt,setLeadStatus:ut,setIncludeHistorical:P,setLimit:ot,onRefresh:Ln,onSort:e=>Yt(`gigs`,e),onOpenGig:W,onCloseGig:G,onUpdateStatus:Qn,onAddApplication:rr,onUpdateApplicationStatus:$n,onSyncLeads:Rn,onReviewLead:Hn,onPostLead:Yn}):null,r===`projects`?(0,N.jsx)(pr,{projects:Rr,selectedProject:Hr,selectedProjectId:M,summary:D,wikiMatches:se,sort:Ke.projects,loading:Be,query:dt,status:pt,canSync:Gt(`projects:sync`),canWrite:Ut(`projects:write`),crmContactUrl:Jt,setQuery:ft,setStatus:mt,onSearch:dn,onSync:fn,onSearchCustomers:pn,onSearchContacts:mn,onSearchAccountManagers:hn,onLoadCostCenters:gn,onCreateProject:_n,onUpdateStatus:vn,onBulkUpdate:yn,onAddUser:bn,onRemoveUser:xn,onAddHistoricalMember:wn,onRemoveHistoricalMember:Dn,onUpdateWikiMatch:On,onWikiMatches:kn,onOpenProject:K,onCloseProject:Xt,onSort:e=>Yt(`projects`,e)}):null,r===`onboarding`?(0,N.jsx)(Mr,{people:Ir,sort:Ke.onboarding,loading:Be,onboardingQuery:kt,onboardingState:jt,onboarderFilter:Nt,onboardingFilters:Ft,onboardingFilterKind:Lt,onboardingFilterValue:zt,onboardingFilterKeys:Yr,onSearch:ur,onSort:e=>Yt(`onboarding`,e),onAssign:Or,onStatusChange:kr,onDraftEmail:dr,onSendEmail:fr,onSetupEngineer:Ar,setOnboardingQuery:At,setOnboardingState:Mt,setOnboarderFilter:Pt,setOnboardingFilterKind:Rt,setOnboardingFilterValue:Bt,addFilter:()=>{It(e=>({...e,[Lt]:zt}))},removeFilter:e=>{It(t=>{let n={...t};return delete n[e],n})},crmContactUrl:Jt,crmAttachmentUrl:H,canWrite:Ut(`onboarding:write`),canConfigure:Ut(`configuration:write`),onOpenConfiguration:()=>{Le({category:`Onboarding`,nonce:Date.now()}),Kt(`configuration`,!0)}}):null,r===`jobs`?(0,N.jsx)(Wr,{jobs:Pr,jobDetail:Re,sort:Ke.jobs,loading:Be,minutes:Je,status:Xe,jobType:Qe,jobCounts:qr,canWrite:Gt(`jobs:write`),setMinutes:Ye,setStatus:Ze,setJobType:$e,onSearch:en,onSort:e=>Yt(`jobs`,e),onDetail:wr,onRerun:Tr}):null,r===`audit`?(0,N.jsx)(Gr,{events:Ur,sort:Ke.audit,loading:Be,onRefresh:mr,onSort:e=>Yt(`audit`,e)}):null,r===`agent`?(0,N.jsx)(Kr,{report:Ee,loading:Be,onRefresh:hr}):null,r===`payments`?(0,N.jsx)(jn,{projects:Ae,rules:Me,suggestions:Pe,loading:Be,canWrite:Ut(`configuration:write`),onRefresh:vr,onCreateRule:yr,onDisableRule:br,onApproveSuggestion:e=>xr(e,`approve`),onRejectSuggestion:e=>xr(e,`reject`)}):null,r===`configuration`?(0,N.jsx)(Sn,{items:Oe,loading:Be,canWrite:Ut(`configuration:write`),jobChannels:te,availableJobChannels:T,focusCategory:Ie?.category,focusNonce:Ie?.nonce,onRefresh:_r,onRefreshJobChannels:()=>on({includeAvailable:!0}),onSave:Sr,onClear:Cr,onSaveJobChannel:cn,onDeleteJobChannel:ln}):null]})]})]})}function er({open:e,notifications:t,loading:n,onClose:r,onRefresh:i,onOpenNotification:a}){return e?(0,N.jsxs)(`div`,{className:`fixed inset-0 z-40`,"aria-labelledby":`notificationsTitle`,"aria-modal":`true`,role:`dialog`,children:[(0,N.jsx)(`button`,{type:`button`,className:`absolute inset-0 cursor-default bg-black/45`,"aria-label":`Close notifications`,onClick:r}),(0,N.jsxs)(`aside`,{className:`absolute right-0 top-0 grid h-full w-full max-w-md grid-rows-[auto_minmax(0,1fr)] border-l bg-background shadow-2xl`,children:[(0,N.jsxs)(`div`,{className:`flex items-center justify-between gap-3 border-b p-4`,children:[(0,N.jsxs)(`div`,{className:`grid gap-0.5`,children:[(0,N.jsx)(`strong`,{id:`notificationsTitle`,className:`text-base`,children:`Notifications`}),(0,N.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:t.length===0?`No active notifications`:`${t.length} active`})]}),(0,N.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,N.jsxs)(U,{type:`button`,variant:`outline`,size:`sm`,onClick:i,disabled:n,children:[(0,N.jsx)(ne,{}),`Refresh`]}),(0,N.jsx)(U,{type:`button`,variant:`ghost`,size:`icon`,"aria-label":`Close`,onClick:r,children:(0,N.jsx)(k,{})})]})]}),(0,N.jsx)(`div`,{className:`min-h-0 overflow-auto p-4`,children:t.length===0?(0,N.jsx)(`div`,{className:`rounded-md border border-dashed p-6 text-sm text-muted-foreground`,children:`No active notifications.`}):(0,N.jsx)(`div`,{className:`grid gap-3`,children:t.map(e=>(0,N.jsxs)(`button`,{type:`button`,className:`grid gap-2 rounded-md border p-3 text-left hover:bg-secondary`,onClick:()=>a(e),children:[(0,N.jsx)(`span`,{className:`text-sm font-bold`,children:e.title}),(0,N.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:e.message})]},e.id))})})]})]}):null}function tr({choice:e,loading:t,crmContactUrl:n,onClose:r,onChoose:i}){return e?(0,N.jsxs)(`div`,{className:`fixed inset-0 z-50 grid place-items-center p-4`,"aria-labelledby":`historicalPersonChoiceTitle`,"aria-modal":`true`,role:`dialog`,children:[(0,N.jsx)(`button`,{type:`button`,className:`absolute inset-0 cursor-default bg-black/45`,"aria-label":`Close person selection`,onClick:r}),(0,N.jsxs)(`div`,{className:`relative grid w-full max-w-2xl gap-4 rounded-md border bg-background p-5 shadow-2xl`,children:[(0,N.jsxs)(`div`,{className:`flex items-start justify-between gap-3`,children:[(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`strong`,{id:`historicalPersonChoiceTitle`,className:`block text-base`,children:`Choose person record`}),(0,N.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:e.person})]}),(0,N.jsx)(U,{type:`button`,variant:`ghost`,size:`icon`,"aria-label":`Close person selection`,onClick:r,children:(0,N.jsx)(k,{})})]}),(0,N.jsx)(`div`,{className:`grid gap-2`,children:e.candidates.map(e=>(0,N.jsxs)(`div`,{className:`grid gap-3 rounded-md border p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center`,children:[(0,N.jsxs)(`div`,{className:`min-w-0`,children:[(0,N.jsx)(`strong`,{className:`block truncate`,children:e.label||e.full_name||e.email||`Person`}),(0,N.jsxs)(`div`,{className:`flex flex-wrap gap-x-3 gap-y-1 text-sm text-muted-foreground`,children:[e.email?(0,N.jsx)(`span`,{children:e.email}):null,e.sources?.length?(0,N.jsx)(`span`,{children:e.sources.join(`, `)}):null,e.erpnext_user_id?(0,N.jsxs)(`span`,{children:[`ERP `,e.erpnext_user_id]}):null,e.supplier_erpnext_id?(0,N.jsxs)(`span`,{children:[`Supplier `,e.supplier_erpnext_id]}):null,e.crm_contact_id&&n(e.crm_contact_id)?(0,N.jsx)(`a`,{className:`font-semibold text-primary underline-offset-4 hover:underline`,href:n(e.crm_contact_id),target:`_blank`,rel:`noreferrer`,children:`CRM`}):null]})]}),(0,N.jsx)(U,{type:`button`,disabled:t,onClick:()=>i(e.candidate_id),children:`Select`})]},e.candidate_id))})]})]}):null}function nr({toast:e}){return e.message?(0,N.jsx)(`div`,{id:`toast`,role:`status`,className:Vt(`fixed bottom-5 right-5 z-50 max-w-sm rounded-md border bg-background px-4 py-3 text-sm font-semibold shadow-lg`,e.tone===`ok`&&`border-emerald-500/40 text-emerald-300`,e.tone===`warning`&&`border-amber-500/40 text-amber-200`,e.tone===`error`&&`border-red-500/40 text-red-300`),children:e.message}):null}function rr({filters:e,onRemove:t,suffix:n=`filter`}){return(0,N.jsx)(`fieldset`,{className:`m-0 flex min-h-7 flex-wrap gap-2 border-0 p-0`,"aria-label":`Active filters`,children:Object.entries(e).map(([e,r])=>{let i=In[e],a=i.options.find(([e])=>e===r),o=`${i.label}: ${a?a[1]:r}`;return(0,N.jsxs)(U,{type:`button`,variant:`outline`,size:`sm`,className:`rounded-full`,"aria-label":`Remove ${o} ${n}`,onClick:()=>t(e),children:[o,` x`]},e)})})}var ir=[`lead`,`recruiting`,`contacted`,`filled`,`unknown`,`lost`,`duplicate`,`outdated`],ar=[`suggested`,`interested`,`reviewing`,`contacted`,`accepted`,`unavailable`,`rejected`,`withdrawn`];function or(e){return String(e||``).replace(/[-_]+/g,` `).replace(/\s+/g,` `).trim().replace(/\b\w/g,e=>e.toUpperCase())}function sr(e){let t=[e.last_activity_at,e.last_status_changed_at,e.posted_at,e.created_at].map(e=>e?new Date(e).getTime():NaN).filter(e=>!Number.isNaN(e));return t.length>0?new Date(Math.max(...t)).toISOString():``}function cr(e,t,n){let r=e.status===`contacted`;if(e.status!==`recruiting`&&!r)return null;let i=$t(r?e.last_status_changed_at||e.updated_at||e.created_at:sr(e));return i===null||i<(r?n:t)?null:i}function lr(e){let t=String(e||``).toLowerCase();return t===`open`?`queued`:[`completed`,`closed`].includes(t)?`succeeded`:[`cancelled`,`canceled`].includes(t)?`failed`:`neutral`}function ur(e){let t=String(e||``).trim(),n=/^(\d{4})-(\d{2})-(\d{2})$/.exec(t);if(!n)return Qt(e);let[,r,i,a]=n;return new Date(Number(r),Number(i)-1,Number(a)).toLocaleDateString(void 0,{year:`numeric`,month:`short`,day:`numeric`})}function dr(e){return e.full_name||e.email||e.source_user_id||`Unknown`}function fr(e){let t=e.trim();if(!t)return;let n=Number(t);return Number.isFinite(n)?n:void 0}function pr(e){let t=e.wikiMatches?.matches||[],[n,r]=(0,l.useState)([]),[i,a]=(0,l.useState)(``),[o,s]=(0,l.useState)(``),[c,u]=(0,l.useState)(!1),[d,f]=(0,l.useState)(!1),p=(0,l.useMemo)(()=>e.projects.map(e=>e.id),[e.projects]),m=(0,l.useMemo)(()=>new Set(p),[p]),g=n.filter(e=>m.has(e)),_=e.projects.length>0&&g.length===e.projects.length;(0,l.useEffect)(()=>{r(e=>e.filter(e=>m.has(e)))},[m]);function v(e,t){r(n=>t?Array.from(new Set([...n,e])):n.filter(t=>t!==e))}async function y(){let t={};i&&(t.status=i),o&&(t.project_type=o),await e.onBulkUpdate(g,t)&&(r([]),a(``),s(``),u(!1))}let x=(0,N.jsxs)(W,{className:`grid gap-3 p-4 md:grid-cols-[minmax(0,1fr)_180px_auto_auto_auto] md:items-end`,children:[(0,N.jsxs)(J,{children:[`Search projects`,(0,N.jsx)(q,{id:`projectQuery`,value:e.query,autoComplete:`off`,placeholder:`Project, customer, ERP id`,onChange:t=>e.setQuery(t.target.value),onKeyDown:t=>t.key===`Enter`&&e.onSearch()})]}),(0,N.jsxs)(J,{children:[`Status`,(0,N.jsxs)(Y,{id:`projectStatus`,value:e.status,onChange:t=>e.setStatus(t.target.value),children:[(0,N.jsx)(`option`,{value:`Open`,children:`Open`}),(0,N.jsx)(`option`,{value:``,children:`Any status`})]})]}),(0,N.jsxs)(U,{id:`refreshProjects`,type:`button`,onClick:e.onSearch,disabled:e.loading.projects,children:[(0,N.jsx)(ne,{}),`Refresh`]}),e.canSync?(0,N.jsxs)(U,{id:`syncProjects`,type:`button`,variant:`outline`,onClick:e.onSync,disabled:e.loading.syncProjects,children:[(0,N.jsx)(ne,{}),`Sync ERP`]}):null,(0,N.jsxs)(U,{id:`wikiProjectMatches`,type:`button`,variant:`outline`,onClick:e.onWikiMatches,disabled:e.loading.wikiMatches,children:[(0,N.jsx)(T,{}),`Wiki match`]})]});return e.selectedProjectId&&!e.selectedProject&&e.loading.projects?(0,N.jsxs)(N.Fragment,{children:[x,(0,N.jsxs)(W,{children:[(0,N.jsx)(G,{children:(0,N.jsx)(K,{children:`Project detail`})}),(0,N.jsx)(Xt,{className:`text-sm text-muted-foreground`,children:`Loading project.`})]})]}):e.selectedProjectId&&!e.selectedProject?(0,N.jsxs)(N.Fragment,{children:[x,(0,N.jsxs)(W,{children:[(0,N.jsx)(G,{children:(0,N.jsx)(K,{children:`Project detail`})}),(0,N.jsxs)(Xt,{className:`grid gap-3`,children:[(0,N.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`This project is not in the current result set. Clear filters or refresh the project list.`}),(0,N.jsxs)(U,{type:`button`,variant:`outline`,onClick:e.onCloseProject,children:[(0,N.jsx)(h,{}),`Back to projects`]})]})]})]}):e.selectedProject?(0,N.jsxs)(N.Fragment,{children:[x,(0,N.jsx)(hr,{project:e.selectedProject,loading:e.loading,canWrite:e.canWrite,crmContactUrl:e.crmContactUrl,onBack:e.onCloseProject,onUpdateStatus:e.onUpdateStatus,onAddUser:e.onAddUser,onRemoveUser:e.onRemoveUser,onAddHistoricalMember:e.onAddHistoricalMember,onRemoveHistoricalMember:e.onRemoveHistoricalMember})]}):(0,N.jsxs)(N.Fragment,{children:[x,(0,N.jsxs)(`section`,{className:`grid gap-3 md:grid-cols-2`,"aria-label":`Project summary`,children:[(0,N.jsx)(Zn,{id:`projectMetricOpen`,label:`Open`,value:e.summary.open_project_count||0}),(0,N.jsx)(Zn,{id:`projectMetricTotal`,label:`Projects`,value:e.summary.project_count||0})]}),e.canWrite?(0,N.jsxs)(W,{className:`flex flex-wrap items-center justify-between gap-3 p-4`,children:[(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Selected`}),(0,N.jsxs)(`strong`,{className:`block`,children:[g.length,` project(s)`]})]}),(0,N.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,N.jsxs)(U,{type:`button`,onClick:()=>f(!0),children:[(0,N.jsx)(te,{}),`New project`]}),(0,N.jsx)(U,{type:`button`,variant:`outline`,disabled:g.length===0,onClick:()=>u(!0),children:`Bulk edit`})]})]}):null,d?(0,N.jsx)(mr,{loading:e.loading.createProject,onClose:()=>f(!1),onSearchCustomers:e.onSearchCustomers,onSearchContacts:e.onSearchContacts,onSearchAccountManagers:e.onSearchAccountManagers,onLoadCostCenters:e.onLoadCostCenters,onCreateProject:e.onCreateProject}):null,c?(0,N.jsxs)(`div`,{className:`fixed inset-0 z-50 grid place-items-center p-4`,"aria-labelledby":`bulkProjectEditTitle`,"aria-modal":`true`,role:`dialog`,children:[(0,N.jsx)(`button`,{type:`button`,className:`absolute inset-0 cursor-default bg-black/45`,"aria-label":`Close bulk project edit`,onClick:()=>u(!1)}),(0,N.jsxs)(`div`,{className:`relative grid w-full max-w-lg gap-4 rounded-md border bg-background p-5 shadow-2xl`,children:[(0,N.jsxs)(`div`,{className:`flex items-start justify-between gap-3`,children:[(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`strong`,{id:`bulkProjectEditTitle`,className:`block text-base`,children:`Bulk edit projects`}),(0,N.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[g.length,` selected`]})]}),(0,N.jsx)(U,{type:`button`,variant:`ghost`,size:`icon`,"aria-label":`Close bulk project edit`,onClick:()=>u(!1),children:(0,N.jsx)(k,{})})]}),(0,N.jsxs)(`div`,{className:`grid gap-3`,children:[(0,N.jsx)(`strong`,{className:`text-sm`,children:`Changes`}),(0,N.jsxs)(J,{children:[`Status`,(0,N.jsxs)(Y,{value:i,onChange:e=>a(e.target.value),children:[(0,N.jsx)(`option`,{value:``,children:`No change`}),(0,N.jsx)(`option`,{value:`Open`,children:`Open`}),(0,N.jsx)(`option`,{value:`Completed`,children:`Completed`}),(0,N.jsx)(`option`,{value:`Cancelled`,children:`Cancelled`})]})]}),(0,N.jsxs)(J,{children:[`ERP Type`,(0,N.jsxs)(Y,{value:o,onChange:e=>s(e.target.value),children:[(0,N.jsx)(`option`,{value:``,children:`No change`}),(0,N.jsx)(`option`,{value:`Internal`,children:`Internal`}),(0,N.jsx)(`option`,{value:`External`,children:`External`})]})]})]}),(0,N.jsxs)(`div`,{className:`flex flex-wrap justify-end gap-2`,children:[(0,N.jsx)(U,{type:`button`,variant:`outline`,onClick:()=>u(!1),children:`Cancel`}),(0,N.jsx)(U,{type:`button`,disabled:e.loading.projectsBulkUpdate||g.length===0||!i&&!o,onClick:()=>void y(),children:`Apply changes`})]})]})]}):null,(0,N.jsxs)(W,{children:[(0,N.jsxs)(G,{children:[(0,N.jsx)(K,{children:`ERP projects`}),(0,N.jsx)(`span`,{id:`projectsStatus`,className:`text-sm text-muted-foreground`,children:e.loading.projects?`Loading`:`${e.projects.length} shown | synced ${Qt(e.summary.last_synced_at)}`})]}),(0,N.jsx)(pe,{hidden:e.projects.length!==0,children:`No projects match this view. Sync ERP projects if the cache is empty.`}),(0,N.jsx)(`div`,{className:`overflow-x-auto`,children:(0,N.jsxs)(Ht,{id:`projectsTable`,className:Vt(`min-w-[1100px]`,e.projects.length===0&&`hidden`),"aria-label":`ERP projects`,children:[(0,N.jsx)(Ut,{children:(0,N.jsxs)(Gt,{children:[e.canWrite?(0,N.jsx)(L,{className:`w-[48px]`,children:(0,N.jsx)(`input`,{type:`checkbox`,"aria-label":`Select all visible projects`,checked:_,onChange:e=>{r(e.target.checked?p:[])}})}):null,(0,N.jsx)(B,{className:`w-[24%]`,label:`Project`,scope:`projects`,sort:e.sort,sortKey:`display_name`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[16%]`,label:`Customer`,scope:`projects`,sort:e.sort,sortKey:`customer`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[10%]`,label:`Status`,scope:`projects`,sort:e.sort,sortKey:`status`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(L,{className:`w-[16%]`,children:`Timeline`}),(0,N.jsx)(B,{className:`w-[10%]`,label:`Roster`,scope:`projects`,sort:e.sort,sortKey:`roster_count`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[14%]`,label:`Modified`,scope:`projects`,sort:e.sort,sortKey:`modified`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(L,{children:`ERP`})]})}),(0,N.jsx)(Wt,{id:`projectsBody`,children:e.projects.map(t=>{let n=t.roster_members||[];return(0,N.jsxs)(Gt,{children:[e.canWrite?(0,N.jsx)(R,{children:(0,N.jsx)(`input`,{type:`checkbox`,"aria-label":`Select ${t.display_name}`,checked:g.includes(t.id),onChange:e=>v(t.id,e.target.checked)})}):null,(0,N.jsxs)(R,{children:[(0,N.jsx)(`button`,{type:`button`,className:`text-left font-bold text-primary underline-offset-4 hover:underline`,onClick:()=>e.onOpenProject(t.id),children:t.display_name}),(0,N.jsxs)(`div`,{className:`mt-1 flex flex-wrap items-center gap-1.5`,children:[t.project_type?(0,N.jsx)(H,{variant:`neutral`,children:t.project_type}):null,t.linked_engagement_count?(0,N.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[t.linked_engagement_count,` linked gig`]}):null]})]}),(0,N.jsx)(R,{children:t.customer_erpnext_url?(0,N.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-semibold text-primary underline-offset-4 hover:underline`,href:t.customer_erpnext_url,target:`_blank`,rel:`noreferrer`,children:[t.customer,(0,N.jsx)(b,{className:`size-3.5`})]}):t.customer||`None`}),(0,N.jsx)(R,{children:(0,N.jsx)(H,{variant:lr(t.source_status),children:t.source_status||`Unknown`})}),(0,N.jsx)(R,{children:[t.actual_start_date,t.actual_end_date].filter(Boolean).map(e=>ur(e)).join(` to `)||`Not set`}),(0,N.jsx)(R,{children:(0,N.jsxs)(`div`,{className:`grid gap-1`,children:[(0,N.jsx)(`strong`,{children:n.length}),(0,N.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[n.map(dr).slice(0,4).join(`, `)||`No ERP roster`,n.length>4?` +${n.length-4}`:``]})]})}),(0,N.jsx)(R,{children:Qt(t.source_modified_at)}),(0,N.jsx)(R,{className:`text-xs`,children:t.erpnext_project_url?(0,N.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-mono font-semibold text-primary underline-offset-4 hover:underline`,href:t.erpnext_project_url,target:`_blank`,rel:`noreferrer`,children:[t.erpnext_project_id,(0,N.jsx)(b,{className:`size-3.5`})]}):(0,N.jsx)(`span`,{className:`font-mono`,children:`Unlinked`})})]},t.id)})})]})})]}),e.wikiMatches?(0,N.jsxs)(W,{children:[(0,N.jsxs)(G,{children:[(0,N.jsx)(K,{children:`Wiki match preview`}),(0,N.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[e.wikiMatches.document?.title||`Client & Project Info`,` |`,` `,Qt(e.wikiMatches.document?.updatedAt)]})]}),(0,N.jsx)(`div`,{className:`overflow-x-auto`,children:(0,N.jsxs)(Ht,{id:`wikiMatchesTable`,className:`min-w-[920px]`,"aria-label":`Wiki matches`,children:[(0,N.jsx)(Ut,{children:(0,N.jsxs)(Gt,{children:[(0,N.jsx)(L,{children:`ERP project`}),(0,N.jsx)(L,{children:`Best wiki row`}),(0,N.jsx)(L,{children:`Confidence`}),(0,N.jsx)(L,{children:`Section`}),(0,N.jsx)(L,{children:`Decision`})]})}),(0,N.jsx)(Wt,{children:t.map((t,n)=>{let r=t.project,i=t.best_match?.row||{},a=t.manual_match?.match_status||``,o=r?.id||i.row_key||[i.section,i.Client].filter(Boolean).join(`:`)||`wiki-match-${n}`;return(0,N.jsxs)(Gt,{children:[(0,N.jsx)(R,{children:r?.display_name||`Unknown`}),(0,N.jsxs)(R,{children:[(0,N.jsx)(`strong`,{children:i.Client||`No match`}),(0,N.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:[i.DRI,i.Members].filter(Boolean).join(` | `)})]}),(0,N.jsx)(R,{children:(0,N.jsx)(H,{variant:t.best_match?.confidence===`high`?`succeeded`:t.best_match?.confidence===`medium`?`running`:`neutral`,children:t.best_match?`${t.best_match.confidence} ${t.best_match.score}`:`none`})}),(0,N.jsx)(R,{children:i.section||``}),(0,N.jsx)(R,{children:(0,N.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[a?(0,N.jsx)(H,{variant:a===`confirmed`?`succeeded`:`neutral`,children:a===`no_row`?`No wiki row`:`Confirmed`}):null,e.canWrite&&r?.id?(0,N.jsxs)(N.Fragment,{children:[i.row_key?(0,N.jsx)(U,{type:`button`,variant:`outline`,size:`sm`,disabled:e.loading[`project:${r.id}:wiki`],onClick:()=>void e.onUpdateWikiMatch(r.id,`confirmed`,i.row_key),children:`Confirm`}):null,(0,N.jsx)(U,{type:`button`,variant:`outline`,size:`sm`,disabled:e.loading[`project:${r.id}:wiki`],onClick:()=>void e.onUpdateWikiMatch(r.id,`no_row`),children:`No row`})]}):null]})})]},o)})})]})})]}):null]})}function mr(e){let[t,n]=(0,l.useState)(``),[r,i]=(0,l.useState)(`new`),[a,o]=(0,l.useState)(``),[s,c]=(0,l.useState)(``),[u,d]=(0,l.useState)(``),[f,p]=(0,l.useState)([]),[m,h]=(0,l.useState)(``),[g,_]=(0,l.useState)(``),[v,y]=(0,l.useState)([]),[b,x]=(0,l.useState)(`USD`),[S,ee]=(0,l.useState)(``),[C,w]=(0,l.useState)(``),[te,ne]=(0,l.useState)(``),[T,re]=(0,l.useState)(``),[ie,ae]=(0,l.useState)(``),[oe,E]=(0,l.useState)(``),[D,O]=(0,l.useState)(`United States`),[se,ce]=(0,l.useState)(``),[le,ue]=(0,l.useState)(`new`),[A,j]=(0,l.useState)(``),[M,de]=(0,l.useState)(``),[fe,pe]=(0,l.useState)([]),[me,he]=(0,l.useState)(``),[ge,_e]=(0,l.useState)(``),[ve,ye]=(0,l.useState)(``),[be,xe]=(0,l.useState)(``),[Se,Ce]=(0,l.useState)(``),[we,Te]=(0,l.useState)(!1),[Ee,De]=(0,l.useState)([{name:`Projects - 5`,cost_center_name:`Projects`}]),[Oe,ke]=(0,l.useState)(`Projects - 5`),[Ae,je]=(0,l.useState)(``),[Me,Ne]=(0,l.useState)(!1),Pe=(0,l.useRef)(e.onSearchCustomers),Fe=(0,l.useRef)(e.onSearchContacts),Ie=(0,l.useRef)(e.onSearchAccountManagers),Le=(0,l.useRef)(e.onLoadCostCenters),Re=(0,l.useRef)(0),ze=(0,l.useRef)(0),Be=(0,l.useRef)(0),Ve=(0,l.useRef)(0),He=t.trim()?`Engineering for ${t.trim()}`.slice(0,140):``,Ue=[te,T,ie,oe,se].some(e=>e.trim()),We=[me,ge,ve,be,Se].some(e=>e.trim()),Ge=t.trim()&&(r===`new`?a.trim():u.trim())&&!e.loading;(0,l.useEffect)(()=>{Pe.current=e.onSearchCustomers},[e.onSearchCustomers]),(0,l.useEffect)(()=>{Fe.current=e.onSearchContacts},[e.onSearchContacts]),(0,l.useEffect)(()=>{Ie.current=e.onSearchAccountManagers},[e.onSearchAccountManagers]),(0,l.useEffect)(()=>{Le.current=e.onLoadCostCenters},[e.onLoadCostCenters]),(0,l.useEffect)(()=>{let e=!0,t=Re.current+1;return Re.current=t,Le.current().then(n=>{!e||Re.current!==t||(De(n),ke(e=>n.some(t=>t.name===e)?e:`Projects - 5`))}),()=>{e=!1}},[]),(0,l.useEffect)(()=>{if(r!==`existing`){ze.current+=1,p([]);return}let e=!0,t=ze.current+1;ze.current=t;let n=window.setTimeout(()=>{Pe.current(s).then(n=>{!e||ze.current!==t||p(n)})},250);return()=>{e=!1,window.clearTimeout(n)}},[r,s]),(0,l.useEffect)(()=>{if(r!==`new`){Be.current+=1,y([]);return}let e=!0,t=Be.current+1;Be.current=t;let n=window.setTimeout(()=>{Ie.current(m).then(n=>{!e||Be.current!==t||y(n)})},250);return()=>{e=!1,window.clearTimeout(n)}},[r,m]),(0,l.useEffect)(()=>{if(r!==`new`||le!==`existing`){Ve.current+=1,pe([]);return}let e=!0,t=Ve.current+1;Ve.current=t;let n=window.setTimeout(()=>{Fe.current(A).then(n=>{!e||Ve.current!==t||pe(n)})},250);return()=>{e=!1,window.clearTimeout(n)}},[r,le,A]);async function Ke(){Ge&&await e.onCreateProject({project_name:t.trim(),customer_mode:r,customer_name:r===`new`?a.trim():void 0,customer:r===`existing`?u.trim():void 0,account_manager:r===`new`&&g.trim()||void 0,default_billing_currency:r===`new`?b.trim()||`USD`:void 0,default_cost_center:Oe.trim()||`Projects - 5`,activity_type:Me&&Ae.trim()||void 0,customer_details:r===`new`&&S.trim()||void 0,customer_website:r===`new`&&C.trim()||void 0,address_line1:r===`new`&&te.trim()||void 0,address_line2:r===`new`&&T.trim()||void 0,address_city:r===`new`&&ie.trim()||void 0,address_state:r===`new`&&oe.trim()||void 0,address_country:r===`new`&&te.trim()?D.trim()||`United States`:void 0,address_postal_code:r===`new`&&se.trim()||void 0,contact:r===`new`&&le===`existing`&&M.trim()||void 0,contact_first_name:r===`new`&&le===`new`&&me.trim()||void 0,contact_last_name:r===`new`&&le===`new`&&ge.trim()||void 0,contact_email:r===`new`&&le===`new`&&ve.trim()||void 0,contact_phone:r===`new`&&le===`new`&&be.trim()||void 0,contact_mobile:r===`new`&&le===`new`&&Se.trim()||void 0})&&e.onClose()}return(0,N.jsxs)(`div`,{className:`fixed inset-0 z-50 grid place-items-center p-4`,"aria-labelledby":`createProjectTitle`,"aria-modal":`true`,role:`dialog`,children:[(0,N.jsx)(`button`,{type:`button`,className:`absolute inset-0 cursor-default bg-black/45`,"aria-label":`Close project creation`,onClick:e.onClose}),(0,N.jsxs)(`div`,{className:`relative grid max-h-[90vh] w-full max-w-2xl gap-4 overflow-y-auto rounded-md border bg-background p-5 shadow-2xl`,children:[(0,N.jsxs)(`div`,{className:`flex items-start justify-between gap-3`,children:[(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`strong`,{id:`createProjectTitle`,className:`block text-base`,children:`New ERP project`}),(0,N.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:`Creates a project and links a new or existing customer.`})]}),(0,N.jsx)(U,{type:`button`,variant:`ghost`,size:`icon`,"aria-label":`Close project creation`,onClick:e.onClose,children:(0,N.jsx)(k,{})})]}),(0,N.jsxs)(`div`,{className:`grid gap-3`,children:[(0,N.jsxs)(J,{children:[`Project name *`,(0,N.jsx)(q,{value:t,autoComplete:`off`,maxLength:140,placeholder:`Acme Portal`,onChange:e=>n(e.target.value)})]}),(0,N.jsxs)(`div`,{className:`grid gap-2`,children:[(0,N.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Customer`}),(0,N.jsx)(`div`,{className:`grid grid-cols-2 gap-2`,children:[`new`,`existing`].map(e=>(0,N.jsx)(U,{type:`button`,variant:r===e?`default`:`outline`,onClick:()=>i(e),children:e===`new`?`New customer`:`Existing customer`},e))})]}),r===`new`?(0,N.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[(0,N.jsxs)(J,{className:`md:col-span-2`,children:[`Customer name *`,(0,N.jsx)(q,{value:a,autoComplete:`off`,maxLength:140,placeholder:`Acme`,onChange:e=>o(e.target.value)})]}),(0,N.jsxs)(J,{children:[`Account manager`,(0,N.jsx)(q,{value:m,autoComplete:`off`,placeholder:`Search @508.dev user`,onChange:e=>{h(e.target.value),_(``)}})]}),m.trim().length>=2?(0,N.jsx)(`div`,{className:`grid max-h-40 gap-2 overflow-y-auto rounded-md border p-2 md:col-span-2`,children:v.length?v.map(e=>{let t=e.email||e.name||``;return(0,N.jsxs)(`label`,{className:`flex cursor-pointer items-start gap-2 rounded-sm px-2 py-1.5 hover:bg-secondary`,children:[(0,N.jsx)(`input`,{type:`radio`,name:`erpAccountManager`,value:t,checked:g===t,onChange:()=>{_(t),h(t)}}),(0,N.jsxs)(`span`,{className:`grid gap-0.5 text-sm`,children:[(0,N.jsx)(`strong`,{children:e.full_name||t}),(0,N.jsx)(`span`,{className:`text-muted-foreground`,children:t})]})]},t)}):(0,N.jsx)(`span`,{className:`px-2 py-3 text-sm text-muted-foreground`,children:`No enabled @508.dev users found.`})}):null]}):(0,N.jsxs)(`div`,{className:`grid gap-3`,children:[(0,N.jsxs)(J,{children:[`Find customer *`,(0,N.jsx)(q,{value:s,autoComplete:`off`,placeholder:`Search customer`,onChange:e=>c(e.target.value)})]}),(0,N.jsx)(`div`,{className:`grid max-h-48 gap-2 overflow-y-auto rounded-md border p-2`,children:f.length?f.map(e=>{let t=e.name||e.customer_name||``;return(0,N.jsxs)(`label`,{className:`flex cursor-pointer items-start gap-2 rounded-sm px-2 py-1.5 hover:bg-secondary`,children:[(0,N.jsx)(`input`,{type:`radio`,name:`erpCustomer`,value:t,checked:u===t,onChange:()=>d(t)}),(0,N.jsxs)(`span`,{className:`grid gap-0.5 text-sm`,children:[(0,N.jsx)(`strong`,{children:e.customer_name||t}),(0,N.jsx)(`span`,{className:`text-muted-foreground`,children:[t,e.default_currency].filter(Boolean).join(` | `)})]})]},t)}):(0,N.jsx)(`span`,{className:`px-2 py-3 text-sm text-muted-foreground`,children:`Search at least two characters.`})})]}),r===`new`?(0,N.jsxs)(N.Fragment,{children:[(0,N.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[(0,N.jsxs)(J,{className:`md:col-span-2`,children:[`Customer details`,(0,N.jsx)(`textarea`,{value:S,className:`min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]`,maxLength:2e3,placeholder:`More information`,onChange:e=>ee(e.target.value)})]}),(0,N.jsxs)(J,{className:`md:col-span-2`,children:[`Website`,(0,N.jsx)(q,{value:C,autoComplete:`url`,placeholder:`https://example.com`,onChange:e=>w(e.target.value)})]})]}),(0,N.jsxs)(`div`,{className:`grid gap-3`,children:[(0,N.jsxs)(`div`,{className:`flex items-center justify-between gap-3`,children:[(0,N.jsx)(`strong`,{className:`text-sm text-foreground`,children:`Contact`}),(0,N.jsx)(`div`,{className:`grid grid-cols-2 gap-2`,children:[`new`,`existing`].map(e=>(0,N.jsx)(U,{type:`button`,size:`sm`,variant:le===e?`default`:`outline`,onClick:()=>ue(e),children:e===`new`?`New`:`Existing`},e))})]}),le===`new`?(0,N.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[(0,N.jsxs)(J,{children:[`First name `,We?`*`:``,(0,N.jsx)(q,{value:me,autoComplete:`given-name`,onChange:e=>he(e.target.value)})]}),(0,N.jsxs)(J,{children:[`Last name`,(0,N.jsx)(q,{value:ge,autoComplete:`family-name`,onChange:e=>_e(e.target.value)})]}),(0,N.jsxs)(J,{children:[`Email`,(0,N.jsx)(q,{value:ve,type:`email`,autoComplete:`email`,onChange:e=>ye(e.target.value)})]}),(0,N.jsxs)(J,{children:[`Phone`,(0,N.jsx)(q,{value:be,type:`tel`,autoComplete:`tel`,onChange:e=>xe(e.target.value)})]}),(0,N.jsxs)(J,{children:[`Mobile`,(0,N.jsx)(q,{value:Se,type:`tel`,autoComplete:`tel`,onChange:e=>Ce(e.target.value)})]})]}):(0,N.jsxs)(`div`,{className:`grid gap-3`,children:[(0,N.jsxs)(J,{children:[`Find contact`,(0,N.jsx)(q,{value:A,autoComplete:`off`,placeholder:`Search name or email`,onChange:e=>j(e.target.value)})]}),(0,N.jsx)(`div`,{className:`grid max-h-48 gap-2 overflow-y-auto rounded-md border p-2`,children:fe.length?fe.map(e=>{let t=e.name||``,n=e.full_name||t,r=[{key:`company`,value:e.company_name},{key:`email`,value:e.email_id},{key:`phone`,value:e.phone},{key:`mobile`,value:e.mobile_no}].filter(e=>!!e.value);return(0,N.jsxs)(`label`,{className:`flex cursor-pointer items-start gap-2 rounded-sm px-2 py-1.5 hover:bg-secondary`,children:[(0,N.jsx)(`input`,{type:`radio`,name:`erpContact`,value:t,checked:M===t,onChange:()=>de(t)}),(0,N.jsxs)(`span`,{className:`grid gap-0.5 text-sm`,children:[(0,N.jsx)(`strong`,{children:(0,N.jsx)(Qn,{value:n,query:A})}),r.length?(0,N.jsx)(`span`,{className:`text-muted-foreground`,children:r.map((e,t)=>(0,N.jsxs)(`span`,{children:[t>0?` | `:``,(0,N.jsx)(Qn,{value:e.value,query:A})]},e.key))}):null]})]},t)}):(0,N.jsx)(`span`,{className:`px-2 py-3 text-sm text-muted-foreground`,children:`Search at least two characters.`})})]})]}),(0,N.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[(0,N.jsx)(`strong`,{className:`text-sm text-foreground md:col-span-2`,children:`Address`}),(0,N.jsxs)(J,{className:`md:col-span-2`,children:[`Address line 1 `,Ue?`*`:``,(0,N.jsx)(q,{value:te,autoComplete:`address-line1`,onChange:e=>ne(e.target.value)})]}),(0,N.jsxs)(J,{className:`md:col-span-2`,children:[`Address line 2`,(0,N.jsx)(q,{value:T,autoComplete:`address-line2`,onChange:e=>re(e.target.value)})]}),(0,N.jsxs)(J,{children:[`City`,(0,N.jsx)(q,{value:ie,autoComplete:`address-level2`,onChange:e=>ae(e.target.value)})]}),(0,N.jsxs)(J,{children:[`State`,(0,N.jsx)(q,{value:oe,autoComplete:`address-level1`,onChange:e=>E(e.target.value)})]}),(0,N.jsxs)(J,{children:[`Postal code`,(0,N.jsx)(q,{value:se,autoComplete:`postal-code`,onChange:e=>ce(e.target.value)})]}),(0,N.jsxs)(J,{children:[`Country`,(0,N.jsx)(q,{value:D,autoComplete:`country-name`,onChange:e=>O(e.target.value)})]})]})]}):null,(0,N.jsxs)(`div`,{className:`grid gap-3 rounded-md border p-3`,children:[(0,N.jsx)(U,{type:`button`,variant:`outline`,onClick:()=>Te(e=>!e),children:we?`Hide advanced`:`Show advanced`}),we?(0,N.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[r===`new`?(0,N.jsxs)(J,{children:[`Billing currency`,(0,N.jsx)(q,{value:b,autoComplete:`off`,maxLength:3,onChange:e=>x(e.target.value.toUpperCase())})]}):null,(0,N.jsxs)(J,{children:[`Cost center`,(0,N.jsx)(Y,{value:Oe,onChange:e=>ke(e.target.value),children:Ee.map(e=>{let t=e.name||``;return(0,N.jsx)(`option`,{value:t,children:[t,e.company].filter(Boolean).join(` | `)},t)})})]}),(0,N.jsxs)(J,{children:[`Activity type`,(0,N.jsx)(q,{value:Me?Ae:He,autoComplete:`off`,maxLength:140,placeholder:He||`Engineering for project`,onChange:e=>{Ne(!0),je(e.target.value)}})]})]}):null]})]}),(0,N.jsxs)(`div`,{className:`flex flex-wrap justify-end gap-2`,children:[(0,N.jsx)(U,{type:`button`,variant:`outline`,onClick:e.onClose,children:`Cancel`}),(0,N.jsx)(U,{type:`button`,disabled:!Ge,onClick:()=>void Ke(),children:`Create project`})]})]})]})}function hr(e){let t=e.project,n=t.roster_members||[],[r,i]=(0,l.useState)(``),[a,o]=(0,l.useState)([]),[s,c]=(0,l.useState)(``),[u,d]=(0,l.useState)(``),[f,p]=(0,l.useState)(``),[m,g]=(0,l.useState)(``),_=[t.actual_start_date||t.expected_start_date,t.actual_end_date||t.expected_end_date].filter(Boolean).map(e=>ur(e)).join(` to `)||`Not set`,v=typeof t.percent_complete==`number`?`${Math.round(t.percent_complete)}%`:`Not set`,y=a.find(e=>e.candidate_id===s),x=r.trim().includes(`@`)?r.trim().length>=5:r.trim().length>=3,S=!!(u.trim()||f.trim()||m.trim()),ee=fr(f),C=fr(m),w=!!((f.trim()||m.trim())&&!u.trim()),te=!!(u.trim()&&(!f.trim()||!m.trim())),ne=!!(f.trim()&&ee===void 0)||!!(m.trim()&&C===void 0),T=w||te||ne||ee!==void 0&&ee<0||C!==void 0&&C<0,re=S&&!T?{activity_type:u.trim(),billing_rate:ee,costing_rate:C}:void 0;(0,l.useEffect)(()=>{if(!e.canWrite)return;let t=r.trim();if(s&&y&&t===(y.email||y.label||``))return;if(s&&c(``),!(t.includes(`@`)?t.length>=5:t.length>=3)){o([]);return}let n=new AbortController,i=window.setTimeout(()=>{X(`/dashboard/api/project-member-candidates?query=${encodeURIComponent(t)}`,{signal:n.signal}).then(e=>o(e)).catch(e=>{e instanceof DOMException&&e.name===`AbortError`||o([])})},500);return()=>{n.abort(),window.clearTimeout(i)}},[r,e.canWrite,y,s]);function ie(e){c(e.candidate_id),i(e.email||e.label||e.full_name||r)}return(0,N.jsxs)(N.Fragment,{children:[(0,N.jsxs)(W,{children:[(0,N.jsx)(G,{children:(0,N.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-[auto_minmax(0,1fr)_auto] md:items-start`,children:[(0,N.jsxs)(U,{type:`button`,variant:`outline`,onClick:e.onBack,children:[(0,N.jsx)(h,{}),`Projects`]}),(0,N.jsxs)(`div`,{className:`min-w-0`,children:[(0,N.jsx)(K,{children:t.display_name}),(0,N.jsxs)(`div`,{className:`mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground`,children:[(0,N.jsx)(H,{variant:lr(t.source_status),children:t.source_status||`Unknown`}),t.erpnext_project_id?(0,N.jsx)(`span`,{className:`font-mono`,children:t.erpnext_project_id}):null,t.last_synced_at?(0,N.jsxs)(`span`,{children:[`Synced `,Qt(t.last_synced_at)]}):null]})]}),(0,N.jsxs)(`div`,{className:`flex flex-wrap justify-start gap-2 md:justify-end`,children:[e.canWrite?(0,N.jsxs)(Y,{className:`w-[160px]`,"aria-label":`Status for ${t.display_name}`,value:t.source_status||``,disabled:e.loading[`project:${t.id}:status`],onChange:n=>e.onUpdateStatus(t.id,n.target.value),children:[(0,N.jsx)(`option`,{value:`Open`,children:`Open`}),(0,N.jsx)(`option`,{value:`Completed`,children:`Completed`}),(0,N.jsx)(`option`,{value:`Cancelled`,children:`Cancelled`})]}):null,t.erpnext_project_url?(0,N.jsxs)(`a`,{className:`inline-flex min-h-9 items-center justify-center gap-2 rounded-md border bg-secondary px-3 text-sm font-semibold`,href:t.erpnext_project_url,target:`_blank`,rel:`noreferrer`,children:[(0,N.jsx)(b,{className:`size-4`}),`ERP project`]}):null,t.customer_erpnext_url?(0,N.jsxs)(`a`,{className:`inline-flex min-h-9 items-center justify-center gap-2 rounded-md border bg-secondary px-3 text-sm font-semibold`,href:t.customer_erpnext_url,target:`_blank`,rel:`noreferrer`,children:[(0,N.jsx)(b,{className:`size-4`}),`ERP customer`]}):null]})]})}),(0,N.jsxs)(Xt,{className:`grid gap-4 md:grid-cols-2 lg:grid-cols-4`,children:[(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Customer`}),(0,N.jsx)(`strong`,{className:`block`,children:t.customer||`None`})]}),(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Timeline`}),(0,N.jsx)(`strong`,{className:`block`,children:_})]}),(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Progress`}),(0,N.jsx)(`strong`,{className:`block`,children:v})]}),(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Linked Gigs`}),(0,N.jsx)(`strong`,{className:`block`,children:t.linked_engagement_count||0})]}),(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`ERP Type`}),(0,N.jsx)(`div`,{className:`mt-1`,children:t.project_type?(0,N.jsx)(H,{variant:`neutral`,children:t.project_type}):(0,N.jsx)(`strong`,{className:`block`,children:`Not set`})})]}),(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`ERP Modified`}),(0,N.jsx)(`strong`,{className:`block`,children:Qt(t.source_modified_at)})]}),(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Cache ID`}),(0,N.jsx)(`strong`,{className:`block break-all font-mono text-xs`,children:t.id})]})]})]}),(0,N.jsxs)(W,{children:[(0,N.jsxs)(G,{children:[(0,N.jsx)(K,{children:`Project roster`}),(0,N.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:n.length?`${n.length} synced ERP user${n.length===1?``:`s`}`:`No ERP roster`})]}),e.canWrite?(0,N.jsxs)(Xt,{className:`grid gap-3 border-b md:grid-cols-[minmax(260px,1fr)_minmax(180px,.7fr)_minmax(130px,.45fr)_minmax(130px,.45fr)_auto_auto] md:items-end`,children:[(0,N.jsxs)(`div`,{className:`relative`,children:[(0,N.jsxs)(J,{children:[`Person search`,(0,N.jsx)(q,{value:r,autoComplete:`off`,placeholder:`Search @508.dev person`,onChange:e=>i(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),a.length===1&&ie(a[0]))}})]}),x&&!s?(0,N.jsx)(`div`,{className:`absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border bg-background shadow-lg`,children:a.length?a.map(e=>(0,N.jsxs)(`button`,{type:`button`,className:`grid w-full gap-0.5 px-3 py-2 text-left hover:bg-secondary focus:bg-secondary focus:outline-none`,onClick:()=>ie(e),children:[(0,N.jsx)(`span`,{className:`truncate text-sm font-bold`,children:e.label||e.full_name||e.email||`Person`}),(0,N.jsx)(`span`,{className:`truncate text-xs text-muted-foreground`,children:[e.email,e.sources?.join(`, `)].filter(Boolean).join(` | `)})]},e.candidate_id)):(0,N.jsx)(`div`,{className:`px-3 py-2 text-sm text-muted-foreground`,children:`No verified @508.dev results`})}):null]}),(0,N.jsxs)(J,{children:[`Activity Type`,(0,N.jsx)(q,{value:u,autoComplete:`off`,placeholder:`Optional rate step`,onChange:e=>d(e.target.value)})]}),(0,N.jsxs)(J,{children:[`Billing rate`,(0,N.jsx)(q,{value:f,inputMode:`decimal`,autoComplete:`off`,placeholder:`USD/hr`,onChange:e=>p(e.target.value)})]}),(0,N.jsxs)(J,{children:[`Costing rate`,(0,N.jsx)(q,{value:m,inputMode:`decimal`,autoComplete:`off`,placeholder:`USD/hr`,onChange:e=>g(e.target.value)})]}),(0,N.jsxs)(U,{type:`button`,variant:`outline`,disabled:e.loading[`project:${t.id}:user`]||!s||!y?.email||T,onClick:()=>void e.onAddUser(t.id,y?.email||r,s,re).then(e=>{e&&(i(``),o([]),c(``),d(``),p(``),g(``))}),children:[(0,N.jsx)(O,{}),`Add ERP user`]}),(0,N.jsxs)(U,{type:`button`,variant:`outline`,disabled:e.loading[`project:${t.id}:historical`]||!r.trim(),onClick:()=>void e.onAddHistoricalMember(t.id,r).then(e=>{e&&i(``)}),children:[(0,N.jsx)(O,{}),`Add historical`]})]}):null,(0,N.jsx)(`div`,{className:`overflow-x-auto`,children:(0,N.jsxs)(Ht,{className:`min-w-[860px]`,"aria-label":`Project roster`,children:[(0,N.jsx)(Ut,{children:(0,N.jsxs)(Gt,{children:[(0,N.jsx)(L,{children:`Name`}),(0,N.jsx)(L,{children:`Email`}),(0,N.jsx)(L,{children:`ERP user`}),(0,N.jsx)(L,{children:`Links`}),(0,N.jsx)(L,{children:`Source`}),(0,N.jsx)(L,{children:`Last seen`}),e.canWrite?(0,N.jsx)(L,{children:`Actions`}):null]})}),(0,N.jsx)(Wt,{children:n.length?n.map(n=>{let r=dr(n),i=n.source_user_id||n.email||``,a=n.roster_kind===`historical`||n.source===`manual`;return(0,N.jsxs)(Gt,{children:[(0,N.jsx)(R,{children:(0,N.jsx)(`strong`,{children:n.full_name||n.email||n.source_user_id})}),(0,N.jsx)(R,{children:n.email||`None`}),(0,N.jsx)(R,{className:`font-mono text-xs`,children:n.erpnext_user_url?(0,N.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-semibold text-primary underline-offset-4 hover:underline`,href:n.erpnext_user_url,target:`_blank`,rel:`noreferrer`,children:[n.source_user_id||`ERP user`,(0,N.jsx)(b,{className:`size-3.5`})]}):n.source_user_id||`Unknown`}),(0,N.jsx)(R,{children:(0,N.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[n.supplier_erpnext_url?(0,N.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-semibold text-primary underline-offset-4 hover:underline`,href:n.supplier_erpnext_url,target:`_blank`,rel:`noreferrer`,children:[`Supplier`,(0,N.jsx)(b,{className:`size-3.5`})]}):null,n.crm_contact_id&&e.crmContactUrl(n.crm_contact_id)?(0,N.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-semibold text-primary underline-offset-4 hover:underline`,href:e.crmContactUrl(n.crm_contact_id),target:`_blank`,rel:`noreferrer`,children:[`CRM`,(0,N.jsx)(b,{className:`size-3.5`})]}):null,!n.supplier_erpnext_url&&!(n.crm_contact_id&&e.crmContactUrl(n.crm_contact_id))?(0,N.jsx)(`span`,{className:`text-muted-foreground`,children:`None`}):null]})}),(0,N.jsx)(R,{children:n.roster_kind||n.source||`ERP`}),(0,N.jsx)(R,{children:Qt(n.last_seen_at)}),e.canWrite?(0,N.jsx)(R,{children:(0,N.jsxs)(U,{type:`button`,variant:`outline`,size:`sm`,disabled:!i||e.loading[`project:${t.id}:${a?`historical`:`user`}`],onClick:()=>{window.confirm(`Remove ${r} from this project roster?`)&&(a?e.onRemoveHistoricalMember(t.id,i):e.onRemoveUser(t.id,i))},children:[(0,N.jsx)(E,{}),`Remove`]})}):null]},`${n.source||``}:${n.source_user_id||n.email}`)}):(0,N.jsx)(Gt,{children:(0,N.jsx)(R,{colSpan:e.canWrite?7:6,className:`text-sm text-muted-foreground`,children:`No roster rows have been synced for this project.`})})})]})})]})]})}function gr(e){let t=e.gigs.reduce((t,n)=>(t.total+=1,t.applications+=Number(n.application_count||0),t.interested+=Number(n.interested_count||0),cr(n,e.staleDays,e.contactedReminderDays)!==null&&(t.stale+=1),t),{total:0,applications:0,interested:0,stale:0}),n=e.leads.reduce((e,t)=>(e.total+=1,t.status===`pending`&&(e.pending+=1),t.status===`approved`&&(e.approved+=1),t.status===`posted`&&(e.posted+=1),e),{total:0,pending:0,approved:0,posted:0}),r=(0,N.jsxs)(W,{className:`grid gap-3 p-4`,children:[(0,N.jsxs)(`div`,{className:`inline-flex w-fit rounded-md border bg-background p-1`,role:`tablist`,"aria-label":`Gig views`,children:[(0,N.jsxs)(U,{id:`gigsTab`,type:`button`,size:`sm`,variant:e.activeTab===`gigs`?`default`:`ghost`,"aria-pressed":e.activeTab===`gigs`,onClick:()=>e.setActiveTab(`gigs`),children:[(0,N.jsx)(_,{}),`Gigs`]}),(0,N.jsxs)(U,{id:`gigLeadsTab`,type:`button`,size:`sm`,variant:e.activeTab===`leads`?`default`:`ghost`,"aria-pressed":e.activeTab===`leads`,onClick:()=>e.setActiveTab(`leads`),children:[(0,N.jsx)(y,{}),`Leads`]})]}),e.activeTab===`leads`?(0,N.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-[minmax(160px,.6fr)_auto_auto] md:items-end`,children:[(0,N.jsxs)(J,{children:[`Lead status`,(0,N.jsxs)(Y,{id:`gigLeadStatus`,value:e.leadStatus,onChange:t=>e.setLeadStatus(t.target.value),children:[(0,N.jsx)(`option`,{value:`pending`,children:`Pending`}),(0,N.jsx)(`option`,{value:`approved`,children:`Approved`}),(0,N.jsx)(`option`,{value:`rejected`,children:`Rejected`}),(0,N.jsx)(`option`,{value:`posted`,children:`Posted`}),(0,N.jsx)(`option`,{value:`all`,children:`All statuses`})]})]}),(0,N.jsxs)(U,{id:`refreshGigLeads`,type:`button`,variant:`outline`,onClick:e.onRefresh,disabled:e.loading.gigLeads,children:[(0,N.jsx)(ne,{}),`Refresh`]}),e.canManageLeads?(0,N.jsxs)(U,{id:`syncGigLeads`,type:`button`,onClick:e.onSyncLeads,disabled:e.loading.gigLeadsSync,children:[(0,N.jsx)(ne,{}),`Scrape HN`]}):null]}):(0,N.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-[minmax(140px,.75fr)_minmax(220px,1.25fr)_auto_auto_auto] md:items-end`,children:[(0,N.jsxs)(J,{children:[`Status`,(0,N.jsxs)(Y,{id:`gigStatus`,value:e.status,onChange:t=>e.setStatus(t.target.value),children:[(0,N.jsx)(`option`,{value:``,children:`Any status`}),ir.map(e=>(0,N.jsx)(`option`,{value:e,children:or(e)},e))]})]}),(0,N.jsxs)(J,{children:[`Search gigs`,(0,N.jsx)(q,{id:`gigQuery`,value:e.query,autoComplete:`off`,placeholder:`Title, gig text, #tag, @poster`,onChange:t=>e.setQuery(t.target.value),onKeyDown:t=>t.key===`Enter`&&e.onRefresh()})]}),e.canIncludeHistorical?(0,N.jsxs)(`label`,{className:`flex min-h-9 items-center gap-2 text-xs font-bold text-muted-foreground`,children:[(0,N.jsx)(`input`,{type:`checkbox`,checked:e.includeHistorical,onChange:t=>e.setIncludeHistorical(t.target.checked)}),`Include historical`]}):null,(0,N.jsxs)(U,{id:`searchGigs`,type:`button`,onClick:e.onRefresh,disabled:e.loading.gigs,children:[(0,N.jsx)(T,{}),`Search`]}),(0,N.jsxs)(U,{id:`refreshGigs`,type:`button`,variant:`outline`,onClick:e.onRefresh,disabled:e.loading.gigs,children:[(0,N.jsx)(ne,{}),`Refresh`]}),e.gigs.length>=e.limit?(0,N.jsx)(U,{type:`button`,variant:`outline`,onClick:()=>e.setLimit(Math.min(e.limit+100,500)),disabled:e.loading.gigs||e.limit>=500,children:`Load more`}):null]})]});if(e.activeTab===`leads`)return(0,N.jsxs)(N.Fragment,{children:[r,(0,N.jsxs)(`section`,{className:`grid gap-3 md:grid-cols-4`,"aria-label":`Lead summary`,children:[(0,N.jsx)(Zn,{id:`gigLeadMetricTotal`,label:`Leads`,value:n.total}),(0,N.jsx)(Zn,{id:`gigLeadMetricPending`,label:`Pending`,value:n.pending}),(0,N.jsx)(Zn,{id:`gigLeadMetricApproved`,label:`Approved`,value:n.approved}),(0,N.jsx)(Zn,{id:`gigLeadMetricPosted`,label:`Posted`,value:n.posted})]}),(0,N.jsxs)(W,{children:[(0,N.jsxs)(G,{children:[(0,N.jsx)(K,{children:`Sourced leads`}),(0,N.jsx)(`span`,{id:`gigLeadsStatus`,className:`text-sm text-muted-foreground`,children:e.loading.gigLeads?`Loading`:`${e.leads.length} shown`})]}),(0,N.jsx)(pe,{hidden:e.leads.length!==0,children:`No sourced leads match this view.`}),(0,N.jsx)(`div`,{id:`gigLeadsBody`,className:Vt(`grid gap-3 p-4`,e.leads.length===0&&`hidden`),children:e.leads.map(t=>(0,N.jsx)(wr,{lead:t,loading:e.loading,canWrite:e.canManageLeads,jobPostChannels:e.jobPostChannels,onReviewLead:e.onReviewLead,onPostLead:e.onPostLead},t.id))})]})]});let i=e.selectedGigId?e.loading[`gig:${e.selectedGigId}:detail`]:!1;return e.selectedGigId&&!e.selectedGig&&(e.loading.gigs||i)?(0,N.jsxs)(N.Fragment,{children:[r,(0,N.jsxs)(W,{children:[(0,N.jsx)(G,{children:(0,N.jsx)(K,{children:`Gig detail`})}),(0,N.jsx)(Xt,{className:`text-sm text-muted-foreground`,children:`Loading gig.`})]})]}):e.selectedGigId&&!e.selectedGig?(0,N.jsxs)(N.Fragment,{children:[r,(0,N.jsxs)(W,{children:[(0,N.jsx)(G,{children:(0,N.jsx)(K,{children:`Gig detail`})}),(0,N.jsxs)(Xt,{className:`grid gap-3`,children:[(0,N.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`This gig is not in the current result set. Clear filters or refresh the gig list.`}),(0,N.jsxs)(U,{type:`button`,variant:`outline`,onClick:e.onCloseGig,children:[(0,N.jsx)(h,{}),`Back to gigs`]})]})]})]}):e.selectedGig?(0,N.jsxs)(N.Fragment,{children:[r,(0,N.jsx)(kr,{gig:e.selectedGig,loading:e.loading,canWrite:e.canWrite,canSearchCandidates:e.canSearchCandidates,crmContactUrl:e.crmContactUrl,crmAttachmentUrl:e.crmAttachmentUrl,staleDays:e.staleDays,contactedReminderDays:e.contactedReminderDays,onBack:e.onCloseGig,onUpdateStatus:e.onUpdateStatus,onAddApplication:e.onAddApplication,onUpdateApplicationStatus:e.onUpdateApplicationStatus},e.selectedGig.id)]}):(0,N.jsxs)(N.Fragment,{children:[r,(0,N.jsxs)(`section`,{className:`grid gap-3 md:grid-cols-4`,"aria-label":`Gig summary`,children:[(0,N.jsx)(Zn,{id:`gigMetricTotal`,label:`Gigs`,value:t.total}),(0,N.jsx)(Zn,{id:`gigMetricCandidates`,label:`Candidates`,value:t.applications}),(0,N.jsx)(Zn,{id:`gigMetricInterested`,label:`Interested`,value:t.interested}),(0,N.jsx)(Zn,{id:`gigMetricStale`,label:`Needs update`,value:t.stale})]}),(0,N.jsxs)(W,{children:[(0,N.jsxs)(G,{children:[(0,N.jsx)(K,{children:`Discord gigs`}),(0,N.jsxs)(`div`,{className:`flex flex-wrap items-center justify-end gap-2`,children:[(0,N.jsxs)(U,{type:`button`,variant:`ghost`,size:`sm`,onClick:()=>e.onSort(`activity`),"aria-label":`Sort gigs by activity`,children:[`Activity`,` `,e.sort.key===`activity`?e.sort.direction===`asc`?`↑`:`↓`:``]}),(0,N.jsxs)(U,{type:`button`,variant:`ghost`,size:`sm`,onClick:()=>e.onSort(`title`),"aria-label":`Sort gigs by title`,children:[`Title `,e.sort.key===`title`?e.sort.direction===`asc`?`↑`:`↓`:``]}),(0,N.jsx)(`span`,{id:`gigsStatus`,className:`text-sm text-muted-foreground`,children:e.loading.gigs?`Loading`:`${e.gigs.length} shown`})]})]}),(0,N.jsx)(pe,{hidden:e.gigs.length!==0,children:`No gigs match this view.`}),(0,N.jsx)(`div`,{id:`gigsBody`,className:Vt(`grid gap-3 p-4`,e.gigs.length===0&&`hidden`),children:e.gigs.map(t=>(0,N.jsx)(Er,{gig:t,loading:e.loading,canWrite:e.canWrite,staleDays:e.staleDays,contactedReminderDays:e.contactedReminderDays,onOpenGig:e.onOpenGig,onUpdateStatus:e.onUpdateStatus},t.id))})]})]})}function _r(e,t){if(t.length===0)return``;let n=String(e.posting_type||``).trim();return(t.find(e=>e.posting_type===n)||t.find(e=>e.posting_type===`part_time`)||t.find(e=>e.posting_type===`part_time_or_full_time`)||t.find(e=>e.posting_type===`unknown`)||t[0])?.channel_id||``}function vr(e){let t=e.channel_name?`#${e.channel_name}`:`#${e.channel_id}`;return`${or(e.posting_type)} ${t}`}function yr(e){return String(e||``).trim().toLowerCase().replace(/[^a-z0-9]+/g,` `).trim()}function br(e){let t=new Set((e.tags||[]).map(yr).filter(Boolean));e.remote&&t.add(`remote`);let n=String(e.posting_type||``);if(n===`part_time`)for(let e of[`part time`,`contract`,`contractor`,`freelance`,`gig`,`1099`])t.add(e);if(n===`full_time`)for(let e of[`full time`,`fulltime`,`employee`,`permanent`])t.add(e);if(n===`part_time_or_full_time`)for(let e of[`part time`,`full time`,`contract`,`employee`])t.add(e);return t.has(`contract to hire`)&&(t.add(`contract`),t.add(`contractor`)),t.has(`1099`)&&(t.add(`contract`),t.add(`contractor`)),t}function xr(e,t,n){let r=yr(e.name);if(!r)return-1;if(n.has(r))return 100;let i=yr([t.title,t.body_normalized,t.location,...t.tags||[]].join(` `));if(r===`remote`&&t.remote)return 90;if(i.includes(r))return 80;let a=String(t.posting_type||``);if(a===`part_time`&&[`part time`,`contract`,`contractor`,`freelance`,`gig`,`1099`].includes(r)||a===`full_time`&&[`full time`,`fulltime`,`employee`,`permanent`].includes(r))return 70;let o=new Set(Array.from(n).join(` `).split(` `).filter(Boolean));return r.split(` `).some(e=>o.has(e))?40:0}function Sr(e,t){let n=t?.available_tags||[];if(n.length===0)return[];let r=br(e),i=n.filter(e=>r.has(yr(e.name))).map(e=>e.name).slice(0,5);if(i.length>0||!t?.requires_tag)return i;let[a]=[...n].sort((t,n)=>{let i=xr(n,e,r)-xr(t,e,r);return i===0?Number(!!t.moderated)-Number(!!n.moderated):i});return a?[a.name]:[]}function Cr(e){let t=e.contractor_classification?.method,n=e.contractor_classification?.posting_type||e.posting_type,r={part_time:`Part-time / contract`,full_time:`Full-time`,part_time_or_full_time:`Full-time or part-time / contract`,unknown:`Employment type unknown`}[String(n||``)]||`Employment type unknown`;return t?`${r} · ${nn(t)}`:n?r:e.review_summary||`Classification unavailable`}function wr({lead:e,loading:t,canWrite:n,jobPostChannels:r,onReviewLead:i,onPostLead:a}){let o=n&&(e.status===`pending`||e.status===`approved`),s=t[`gigLead:${e.id}:review`],c=t[`gigLead:${e.id}:post`],u=o,d=_r(e,r),[f,p]=(0,l.useState)(`lead`),m=(0,l.useRef)(null),[h,g]=(0,l.useState)(d),_=r.find(e=>e.channel_id===h),[v,y]=(0,l.useState)(()=>Sr(e,_));(0,l.useEffect)(()=>{g(d)},[d]),(0,l.useEffect)(()=>{y(Sr(e,_))},[e,_]);let x=!!_?.requires_tag&&!!_?.available_tags?.length&&v.length===0;function S(e){y(t=>t.includes(e)?t.filter(t=>t!==e):[...t,e].slice(0,5))}let ee=e.discord_guild_id&&e.discord_thread_id?`https://discord.com/channels/${encodeURIComponent(e.discord_guild_id)}/${encodeURIComponent(e.discord_thread_id)}`:``,C=e.contractor_classification?.contact_email;return(0,N.jsxs)(`article`,{className:`grid gap-4 rounded-md border bg-background p-4 lg:grid-cols-[minmax(0,1fr)_220px_190px] lg:items-start`,children:[(0,N.jsxs)(`div`,{className:`min-w-0`,children:[(0,N.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[(0,N.jsx)(`strong`,{className:`text-base`,children:e.title||`Untitled lead`}),(0,N.jsx)(H,{variant:Tr(e.status),children:or(e.status)}),e.remote?(0,N.jsx)(H,{variant:`queued`,children:`Remote`}):null,(0,N.jsx)(H,{variant:`neutral`,children:Cr(e)})]}),(0,N.jsxs)(`div`,{className:`mt-2 flex flex-wrap gap-1.5`,children:[e.organization?(0,N.jsx)(H,{variant:`neutral`,children:e.organization}):null,e.location?(0,N.jsx)(H,{variant:`neutral`,children:e.location}):null,(e.tags||[]).slice(0,6).map(e=>(0,N.jsx)(H,{variant:`queued`,children:e},e))]}),(0,N.jsx)(`p`,{className:`mt-3 max-h-20 overflow-hidden text-sm text-muted-foreground`,children:e.body_normalized||`No lead text captured.`}),e.review_summary?(0,N.jsx)(`p`,{className:`mt-2 text-sm font-semibold text-foreground`,children:e.review_summary}):null,(0,N.jsxs)(`div`,{className:`mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground`,children:[(0,N.jsxs)(`span`,{children:[`Posted `,Qt(e.source_posted_at)||`unknown`]}),(0,N.jsxs)(`span`,{children:[`Captured `,Qt(e.created_at)||`unknown`]}),e.source_url?(0,N.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-extrabold text-primary`,href:e.source_url,target:`_blank`,rel:`noreferrer`,children:[`Source`,(0,N.jsx)(b,{className:`size-3.5`})]}):null,e.apply_url?(0,N.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-extrabold text-primary`,href:e.apply_url,target:`_blank`,rel:`noreferrer`,children:[`Apply website`,(0,N.jsx)(b,{className:`size-3.5`})]}):null,C?(0,N.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-extrabold text-primary`,href:`mailto:${C}`,children:[`Email `,C]}):null,ee?(0,N.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-extrabold text-primary`,href:ee,target:`_blank`,rel:`noreferrer`,children:[`Discord`,(0,N.jsx)(b,{className:`size-3.5`})]}):null]})]}),(0,N.jsxs)(`div`,{className:`grid gap-1 text-sm`,children:[(0,N.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Review`}),(0,N.jsx)(`span`,{children:e.reviewed_at?Qt(e.reviewed_at):`Not reviewed`}),(0,N.jsx)(`span`,{className:`text-muted-foreground`,children:e.reviewed_by_discord_user_id?`By ${e.reviewed_by_discord_user_id}`:`No reviewer recorded`}),(0,N.jsx)(`span`,{className:`font-mono text-xs text-muted-foreground`,children:e.id.slice(0,8)})]}),n?(0,N.jsxs)(`div`,{className:`grid gap-2`,children:[(0,N.jsxs)(J,{children:[`Post as`,(0,N.jsxs)(Y,{value:f,ref:m,disabled:!u||c||s,onChange:e=>p(e.target.value===`recruiting`?`recruiting`:`lead`),children:[(0,N.jsx)(`option`,{value:`lead`,children:`Lead`}),(0,N.jsx)(`option`,{value:`recruiting`,children:`Recruiting`})]})]}),r.length>0?(0,N.jsxs)(J,{children:[`Channel`,(0,N.jsx)(Y,{value:h,disabled:!u||c||s,onChange:e=>g(e.target.value),children:r.map(e=>(0,N.jsx)(`option`,{value:e.channel_id,children:vr(e)},e.channel_id))})]}):null,_?.available_tags?.length?(0,N.jsxs)(`fieldset`,{className:`grid gap-1.5`,children:[(0,N.jsxs)(`legend`,{className:`text-xs font-bold text-muted-foreground`,children:[`Tags`,_.requires_tag?` (required)`:``]}),(0,N.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:_.available_tags.map(e=>(0,N.jsxs)(`label`,{className:`inline-flex min-h-8 items-center gap-1.5 rounded-md border px-2 text-xs font-bold text-muted-foreground`,children:[(0,N.jsx)(`input`,{type:`checkbox`,checked:v.includes(e.name),disabled:!u||c||s,onChange:()=>S(e.name)}),e.name]},e.id||e.name))})]}):null,(0,N.jsxs)(U,{type:`button`,disabled:!u||c||s||x,onClick:()=>{let t=m.current?.value===`recruiting`?`recruiting`:`lead`;a(e.id,{channelId:h,engagementStatus:t,tags:v})},children:[(0,N.jsx)(re,{}),`Post to Discord`]}),(0,N.jsxs)(U,{type:`button`,variant:`outline`,disabled:!o||s||c,onClick:()=>i(e.id,`rejected`),children:[(0,N.jsx)(E,{}),`Reject`]})]}):null]})}function Tr(e){return e===`approved`?`queued`:e===`posted`?`succeeded`:e===`rejected`?`failed`:`neutral`}function Er({gig:e,loading:t,canWrite:n,onOpenGig:r,onUpdateStatus:i,staleDays:a,contactedReminderDays:o}){let s=Array.isArray(e.applications)?e.applications:[],c=e.status===`recruiting`||e.status===`contacted`,l=e.discord_guild_id&&e.discord_thread_id?`https://discord.com/channels/${encodeURIComponent(e.discord_guild_id)}/${encodeURIComponent(e.discord_thread_id)}`:``,u=cr(e,a,o);return(0,N.jsxs)(`article`,{className:Vt(`grid gap-4 rounded-md border bg-background p-4 lg:grid-cols-[minmax(0,1fr)_220px_180px] lg:items-start`,!c&&`border-l-4 border-l-muted-foreground/60 bg-secondary/45`),children:[(0,N.jsxs)(`div`,{className:`min-w-0`,children:[(0,N.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[(0,N.jsx)(`a`,{className:`text-base font-extrabold text-primary`,href:`/dashboard/gigs/${encodeURIComponent(e.id)}`,onClick:t=>{t.preventDefault(),r(e.id)},children:e.title||`Untitled gig`}),(0,N.jsx)(H,{variant:e.status===`filled`?`succeeded`:e.status===`lost`||e.status===`duplicate`?`failed`:c?`queued`:`neutral`,children:e.status_label||or(e.status)}),c?null:(0,N.jsx)(H,{variant:`neutral`,children:`Not active`}),u===null?null:(0,N.jsxs)(H,{variant:`running`,children:[u,`d stale`]})]}),(0,N.jsxs)(`div`,{className:`mt-2 flex flex-wrap gap-1.5`,children:[e.posting_type?(0,N.jsx)(H,{variant:`neutral`,children:or(e.posting_type)}):null,e.discord_channel_name?(0,N.jsxs)(H,{variant:`neutral`,children:[`#`,e.discord_channel_name]}):null,(e.required_skills||[]).slice(0,5).map(e=>(0,N.jsx)(H,{variant:`queued`,children:e},e)),(e.preferred_skills||[]).slice(0,3).map(e=>(0,N.jsx)(H,{variant:`neutral`,children:e},e))]}),(0,N.jsxs)(`div`,{className:`mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground`,children:[(0,N.jsxs)(`span`,{children:[`Activity `,Qt(sr(e))||`unknown`]}),(0,N.jsxs)(`span`,{children:[`Posted `,Qt(e.posted_at)||`unknown`]}),l?(0,N.jsx)(`a`,{className:`font-extrabold text-primary`,href:l,target:`_blank`,rel:`noreferrer`,children:`Open Discord thread`}):null]})]}),(0,N.jsxs)(`div`,{className:`grid grid-cols-2 gap-2 text-sm lg:grid-cols-1`,children:[(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`span`,{className:`block text-xs font-bold text-muted-foreground`,children:`People`}),(0,N.jsx)(`strong`,{children:e.application_count||s.length}),(0,N.jsxs)(`span`,{className:`ml-2 text-muted-foreground`,children:[Number(e.interested_count||0),` interested`]})]}),(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`span`,{className:`block text-xs font-bold text-muted-foreground`,children:`Top candidates`}),(0,N.jsx)(`span`,{className:`text-muted-foreground`,children:s.slice(0,3).map(e=>Dr(e)).join(`, `)||`None yet`})]})]}),(0,N.jsxs)(`div`,{className:`grid gap-2`,children:[n?(0,N.jsx)(Y,{"aria-label":`Status for ${e.title||`gig`}`,value:e.status,disabled:t[`gig:${e.id}:status`],onChange:t=>i(e.id,t.target.value),children:ir.map(e=>(0,N.jsx)(`option`,{value:e,children:or(e)},e))}):null,(0,N.jsx)(U,{type:`button`,onClick:()=>r(e.id),children:`Manage people`})]})]})}function Dr(e){return e.name||e.email_508||e.discord_username||(typeof e.evaluation?.discord_username==`string`?e.evaluation.discord_username:``)||`Candidate`}function Or(e){return e.name||e.email_508||e.email||e.crm_contact_id||`CRM person`}function kr({gig:e,loading:t,canWrite:n,canSearchCandidates:r,crmContactUrl:i,crmAttachmentUrl:a,staleDays:o,contactedReminderDays:s,onBack:c,onUpdateStatus:u,onAddApplication:d,onUpdateApplicationStatus:f}){let[p,m]=(0,l.useState)(``),[g,_]=(0,l.useState)([]),[v,y]=(0,l.useState)(null),[x,S]=(0,l.useState)(``),[ee,C]=(0,l.useState)(``),w=Array.isArray(e.applications)?e.applications:[],te=e.status===`recruiting`||e.status===`contacted`,ne=p.trim().length>=2,T=v?.crm_contact_id||``,re=T?i(T)||T:``,ie=r?re:ee.trim(),ae=e.discord_guild_id&&e.discord_thread_id?`https://discord.com/channels/${encodeURIComponent(e.discord_guild_id)}/${encodeURIComponent(e.discord_thread_id)}`:``,oe=cr(e,o,s);(0,l.useEffect)(()=>{if(!n||!r)return;let e=p.trim();if(v&&e===Or(v)){_([]),S(``);return}if(e.length<2){_([]),S(``);return}let t=new AbortController,i=window.setTimeout(()=>{X(`/dashboard/api/people?${new URLSearchParams({limit:`8`,query:e}).toString()}`,{signal:t.signal}).then(e=>{_(e.filter(e=>!!e.crm_contact_id)),S(``)}).catch(e=>{t.signal.aborted||e instanceof DOMException&&e.name===`AbortError`||(_([]),S(Un(e,`Unable to search candidates`)))})},300);return()=>{t.abort(),window.clearTimeout(i)}},[p,r,n,v]);function E(e){y(e),m(Or(e)),_([]),S(``)}return(0,N.jsxs)(`div`,{className:`grid gap-5`,children:[(0,N.jsxs)(W,{className:Vt(!te&&`border-l-4 border-l-muted-foreground/60 bg-secondary/35`),children:[(0,N.jsxs)(G,{className:`items-start`,children:[(0,N.jsxs)(`div`,{className:`grid gap-2`,children:[(0,N.jsxs)(U,{type:`button`,variant:`ghost`,size:`sm`,className:`w-fit`,onClick:c,children:[(0,N.jsx)(h,{}),`Back to gigs`]}),(0,N.jsxs)(`div`,{children:[(0,N.jsx)(K,{className:`text-xl`,children:e.title||`Untitled gig`}),(0,N.jsxs)(`div`,{className:`mt-2 flex flex-wrap gap-1.5`,children:[(0,N.jsx)(H,{variant:e.status===`filled`?`succeeded`:e.status===`lost`||e.status===`duplicate`?`failed`:te?`queued`:`neutral`,children:e.status_label||or(e.status)}),te?null:(0,N.jsx)(H,{variant:`neutral`,children:`Not active`}),oe===null?null:(0,N.jsxs)(H,{variant:`running`,children:[oe,`d stale`]}),e.posting_type?(0,N.jsx)(H,{variant:`neutral`,children:or(e.posting_type)}):null,e.discord_channel_name?(0,N.jsxs)(H,{variant:`neutral`,children:[`#`,e.discord_channel_name]}):null,(e.required_skills||[]).map(e=>(0,N.jsx)(H,{variant:`queued`,children:e},e)),(e.preferred_skills||[]).map(e=>(0,N.jsx)(H,{variant:`neutral`,children:e},e))]})]})]}),(0,N.jsxs)(`div`,{className:`grid min-w-[190px] gap-2`,children:[n?(0,N.jsxs)(J,{children:[`Gig status`,(0,N.jsx)(Y,{"aria-label":`Status for ${e.title||`gig`}`,value:e.status,disabled:t[`gig:${e.id}:status`],onChange:t=>u(e.id,t.target.value),children:ir.map(e=>(0,N.jsx)(`option`,{value:e,children:or(e)},e))})]}):null,ae?(0,N.jsxs)(`a`,{className:`inline-flex min-h-9 items-center justify-center gap-2 rounded-md border bg-secondary px-3 text-sm font-semibold`,href:ae,target:`_blank`,rel:`noreferrer`,children:[(0,N.jsx)(b,{className:`size-4`}),`Discord thread`]}):null]})]}),(0,N.jsxs)(Xt,{className:`grid gap-4 lg:grid-cols-[1fr_1fr_1fr]`,children:[(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Activity`}),(0,N.jsx)(`strong`,{className:`block`,children:Qt(sr(e))||`unknown`}),(0,N.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[`Posted `,Qt(e.posted_at)||`unknown`]})]}),(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`People`}),(0,N.jsx)(`strong`,{className:`block`,children:e.application_count||w.length}),(0,N.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[Number(e.interested_count||0),` interested`]})]}),(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Discord`}),(0,N.jsx)(`strong`,{className:`block`,children:e.discord_channel_name||`No channel`}),(0,N.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:e.discord_thread_id?`Thread ${e.discord_thread_id}`:`No thread`})]})]})]}),(0,N.jsxs)(W,{children:[(0,N.jsxs)(G,{children:[(0,N.jsx)(K,{children:`People`}),(0,N.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[w.length,` candidate`,w.length===1?``:`s`]})]}),n?(0,N.jsxs)(`form`,{className:`grid gap-2 border-t p-4 md:grid-cols-[minmax(220px,1fr)_auto]`,onSubmit:t=>{t.preventDefault(),d(e.id,ie).then(e=>{e&&(m(``),_([]),y(null),C(``))})},children:[r?(0,N.jsxs)(`div`,{className:`relative min-w-0`,children:[(0,N.jsxs)(J,{children:[`Candidate`,(0,N.jsx)(q,{value:p,autoComplete:`off`,placeholder:`Search by name or email`,"aria-label":`Search candidates to add`,onChange:e=>{m(e.target.value),_([]),S(``),y(null)},onKeyDown:e=>{e.key!==`Enter`||g.length!==1||(e.preventDefault(),E(g[0]))}})]}),ne&&!v?(0,N.jsx)(`div`,{className:`absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border bg-background shadow-lg`,children:x?(0,N.jsx)(`div`,{className:`px-3 py-2 text-sm text-destructive`,children:x}):g.length?g.map(e=>{let t=Or(e),n=[e.email_508||e.email,e.contact_type].filter(Boolean).join(` | `);return(0,N.jsxs)(`button`,{type:`button`,className:`grid w-full gap-0.5 px-3 py-2 text-left hover:bg-secondary focus:bg-secondary focus:outline-none`,onClick:()=>E(e),children:[(0,N.jsx)(`span`,{className:`truncate text-sm font-bold`,children:t}),n?(0,N.jsx)(`span`,{className:`truncate text-xs text-muted-foreground`,children:n}):null]},e.crm_contact_id)}):(0,N.jsx)(`div`,{className:`px-3 py-2 text-sm text-muted-foreground`,children:`No candidates match this search`})}):null]}):(0,N.jsxs)(J,{className:`min-w-0`,children:[`CRM profile`,(0,N.jsx)(q,{value:ee,onChange:e=>C(e.target.value),placeholder:`https://crm.508.dev/#Contact/view/...`,"aria-label":`CRM profile for candidate`})]}),(0,N.jsxs)(U,{type:`submit`,className:`self-end`,disabled:t[`gig:${e.id}:addCandidate`]||!ie,children:[(0,N.jsx)(D,{}),`Add candidate`]})]}):null,(0,N.jsx)(pe,{hidden:w.length!==0,children:`No suggested or interested people yet.`}),(0,N.jsx)(`div`,{className:Vt(`grid gap-3 p-4`,w.length===0&&`hidden`),children:w.map(r=>(0,N.jsx)(Ar,{gigId:e.id,application:r,loading:t,canWrite:n,crmContactUrl:i,crmAttachmentUrl:a,onUpdateApplicationStatus:f},r.id))})]})]})}function Ar({gigId:e,application:t,loading:n,canWrite:r,crmContactUrl:i,crmAttachmentUrl:a,onUpdateApplicationStatus:o}){let s=Dr(t),c=i(t.crm_contact_id),l=a(t.latest_resume_id),u=typeof t.fit_score==`number`?`${Math.round(t.fit_score)}/100`:typeof t.match_score==`number`?t.match_score.toFixed(1):``,d=typeof t.evaluation?.llm_summary==`string`?t.evaluation.llm_summary:``;return(0,N.jsxs)(`div`,{className:`grid gap-2 rounded-md border bg-background p-2`,children:[(0,N.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[c?(0,N.jsx)(`a`,{className:`font-extrabold text-primary`,href:c,target:`_blank`,rel:`noopener noreferrer`,children:s}):(0,N.jsx)(`strong`,{children:s}),(0,N.jsx)(H,{variant:t.status===`interested`?`succeeded`:`neutral`,children:or(t.status)}),(0,N.jsx)(H,{variant:`neutral`,children:or(t.source||`manual_add`)}),u?(0,N.jsxs)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:[`Fit `,u]}):null,c?(0,N.jsx)(`a`,{className:`text-xs font-extrabold text-primary`,href:c,target:`_blank`,rel:`noopener noreferrer`,"aria-label":`Open ${s} CRM profile`,children:`CRM profile`}):null,l?(0,N.jsx)(`a`,{className:`text-xs font-extrabold text-primary`,href:l,target:`_blank`,rel:`noopener noreferrer`,children:`Resume`}):null]}),d?(0,N.jsx)(`div`,{className:`text-xs text-muted-foreground`,children:d}):null,r?(0,N.jsx)(Y,{"aria-label":`Candidate status for ${s}`,value:t.status||`suggested`,disabled:n[`application:${t.id}:status`],onChange:n=>o(e,t.id,n.target.value),children:ar.map(e=>(0,N.jsx)(`option`,{value:e,children:or(e)},e))}):null]})}function jr(e){let t=In[e.peopleFilterKind]?.options||[];return(0,N.jsxs)(W,{children:[(0,N.jsxs)(G,{children:[(0,N.jsx)(K,{children:`People lookup`}),(0,N.jsxs)(`div`,{className:`flex flex-wrap items-center justify-end gap-2`,children:[e.canSync?(0,N.jsxs)(U,{id:`syncPeople`,"data-permission":`people:sync`,type:`button`,onClick:e.onSync,disabled:e.loading.syncPeople,children:[(0,N.jsx)(ne,{}),`Sync people`]}):null,e.crmBaseUrl?(0,N.jsx)(`a`,{id:`crmHomeLink`,className:`text-sm font-extrabold text-primary`,href:e.crmBaseUrl,target:`_blank`,rel:`noreferrer`,children:`Open CRM`}):null,(0,N.jsx)(`span`,{id:`peopleStatus`,className:`text-sm text-muted-foreground`,children:e.loading.people?`Loading`:`${e.people.length} shown`})]})]}),(0,N.jsxs)(`div`,{className:`grid gap-3 border-b p-4 md:grid-cols-[minmax(0,1fr)_auto]`,children:[(0,N.jsxs)(J,{children:[`Search CRM people cache`,(0,N.jsx)(q,{id:`peopleQuery`,value:e.peopleQuery,autoComplete:`off`,placeholder:`Name, email, CRM id, Discord, resume`,onChange:t=>e.setPeopleQuery(t.target.value),onKeyDown:t=>{t.key===`Enter`&&e.onSearch()}})]}),(0,N.jsxs)(U,{id:`searchPeople`,type:`button`,onClick:e.onSearch,disabled:e.loading.people,children:[(0,N.jsx)(T,{}),`Search`]})]}),(0,N.jsxs)(`div`,{className:`grid gap-3 border-b bg-background p-4 md:grid-cols-[minmax(120px,.7fr)_minmax(150px,1fr)_minmax(150px,1fr)_auto]`,children:[(0,N.jsxs)(J,{children:[`Member`,(0,N.jsxs)(Y,{id:`peopleMember`,value:e.peopleMember,onChange:t=>e.setPeopleMember(t.target.value),children:[(0,N.jsx)(`option`,{value:``,children:`Any`}),(0,N.jsx)(`option`,{value:`true`,children:`Member`}),(0,N.jsx)(`option`,{value:`false`,children:`Not member`})]})]}),(0,N.jsxs)(J,{children:[`Add filter`,(0,N.jsx)(Y,{id:`peopleFilterKind`,value:e.peopleFilterKind,disabled:e.peopleFilterKeys.length===0,onChange:t=>e.setPeopleFilterKind(t.target.value),children:e.peopleFilterKeys.map(e=>(0,N.jsx)(`option`,{value:e,children:In[e].label},e))})]}),(0,N.jsxs)(J,{children:[`Value`,(0,N.jsx)(Y,{id:`peopleFilterValue`,value:e.peopleFilterValue,onChange:t=>e.setPeopleFilterValue(t.target.value),children:t.map(([e,t])=>(0,N.jsx)(`option`,{value:e,children:t},e))})]}),(0,N.jsx)(U,{id:`addPeopleFilter`,type:`button`,onClick:e.addFilter,disabled:e.peopleFilterKeys.length===0,children:`Add filter`}),(0,N.jsx)(`div`,{id:`activePeopleFilters`,className:`md:col-span-4`,children:(0,N.jsx)(rr,{filters:e.peopleFilters,onRemove:e.removeFilter})})]}),(0,N.jsx)(pe,{hidden:e.people.length!==0,children:`No people match this lookup.`}),(0,N.jsx)(`div`,{className:`overflow-x-auto`,children:(0,N.jsxs)(Ht,{id:`peopleTable`,className:Vt(`min-w-[900px]`,e.people.length===0&&`hidden`),"aria-label":`People lookup results`,children:[(0,N.jsx)(Ut,{children:(0,N.jsxs)(Gt,{children:[(0,N.jsx)(B,{className:`w-[27%]`,label:`Name`,scope:`people`,sort:e.sort,sortKey:`name`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[28%]`,label:`Status`,scope:`people`,sort:e.sort,sortKey:`status`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[20%]`,label:`Discord`,scope:`people`,sort:e.sort,sortKey:`discord`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[25%]`,label:`Resume / skills`,scope:`people`,sort:e.sort,sortKey:`resume`,onSort:(t,n)=>e.onSort(n)})]})}),(0,N.jsx)(Wt,{id:`peopleBody`,children:e.people.map(t=>{let n=t.name||t.email_508||t.email||`CRM contact`,r=e.crmContactUrl(t.crm_contact_id),i=t.profile_status||{},a=Number(i.skills_count||0),o=e.crmAttachmentUrl(t.latest_resume_id),s=t.latest_intake_submission,c=t.latest_resume_intake_submission||s,l=zr(c),u=o||l,d=o?`Resume`:Br(c)||t.latest_resume_name||`Resume`;return(0,N.jsxs)(Gt,{children:[(0,N.jsxs)(R,{children:[r?(0,N.jsx)(`a`,{className:`font-extrabold text-primary`,href:r,target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${n} in CRM`,children:n}):(0,N.jsx)(`strong`,{children:n}),(0,N.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:[t.email_508||t.email,t.contact_type].filter(Boolean).join(` | `)})]}),(0,N.jsx)(R,{children:(0,N.jsxs)(`div`,{className:`flex flex-wrap gap-1.5`,children:[i.crm_active?null:(0,N.jsx)(H,{variant:`missing`,children:t.sync_status||`CRM sync issue`}),(0,N.jsx)(H,{variant:i.is_member?`succeeded`:`missing`,children:i.is_member?`Member`:`Missing Member`}),(0,N.jsx)(H,{variant:i.discord_linked?`succeeded`:`missing`,children:i.discord_linked?`Discord`:`Missing Discord`}),(0,N.jsx)(H,{variant:i.email_508?`succeeded`:`missing`,children:i.email_508?`508 email`:`Missing 508 email`}),i.latest_resume?null:(0,N.jsx)(H,{variant:`missing`,children:`Missing Resume`})]})}),(0,N.jsx)(R,{children:[t.discord_username,t.discord_user_id].filter(Boolean).join(` | `)||`Not linked`}),(0,N.jsx)(R,{children:(0,N.jsxs)(`div`,{className:`flex flex-wrap items-center gap-1.5`,children:[u?(0,N.jsx)(`a`,{className:`inline-flex min-h-7 max-w-40 items-center truncate rounded-md border bg-secondary px-2 text-xs font-extrabold`,href:u,target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${n} resume`,title:d,children:d}):(0,N.jsx)(`span`,{children:t.latest_resume_name||t.latest_resume_id||`No resume`}),(0,N.jsx)(H,{variant:a>0?`succeeded`:`missing`,children:a>0?`Skills parsed`:`Skills not parsed`})]})})]},t.crm_contact_id||n)})})]})})]})}function Mr(e){let t=In[e.onboardingFilterKind]?.options||[];return(0,N.jsxs)(N.Fragment,{children:[e.canWrite?(0,N.jsx)(Hr,{loading:e.loading.engineerSetup,onSetup:e.onSetupEngineer}):null,(0,N.jsxs)(W,{children:[(0,N.jsxs)(G,{children:[(0,N.jsx)(K,{children:`Onboarding queue`}),(0,N.jsx)(`span`,{id:`onboardingStatus`,className:`text-sm text-muted-foreground`,children:e.loading.onboarding?`Loading`:`${e.people.length} shown`})]}),(0,N.jsxs)(`div`,{className:`grid gap-3 border-b p-4 md:grid-cols-[minmax(0,1fr)_auto]`,children:[(0,N.jsxs)(J,{children:[`Search prospects`,(0,N.jsx)(q,{id:`onboardingQuery`,value:e.onboardingQuery,autoComplete:`off`,placeholder:`Name, email, Discord, onboarder`,onChange:t=>e.setOnboardingQuery(t.target.value),onKeyDown:t=>t.key===`Enter`&&e.onSearch()})]}),(0,N.jsxs)(U,{id:`searchOnboarding`,type:`button`,onClick:e.onSearch,disabled:e.loading.onboarding,children:[(0,N.jsx)(T,{}),`Search`]})]}),(0,N.jsxs)(`div`,{className:`grid gap-3 border-b bg-background p-4 md:grid-cols-[minmax(140px,.8fr)_minmax(150px,1fr)_minmax(150px,1fr)_minmax(120px,.7fr)_auto]`,children:[(0,N.jsxs)(J,{children:[`Status`,(0,N.jsxs)(Y,{id:`onboardingState`,value:e.onboardingState,onChange:t=>e.setOnboardingState(t.target.value),children:[(0,N.jsx)(`option`,{value:``,children:`Any state`}),Rn.map(([e,t])=>(0,N.jsx)(`option`,{value:e,children:t},e))]})]}),(0,N.jsxs)(J,{children:[`Onboarder`,(0,N.jsx)(q,{id:`onboarderFilter`,value:e.onboarderFilter,autoComplete:`off`,placeholder:`Any onboarder`,onChange:t=>e.setOnboarderFilter(t.target.value),onKeyDown:t=>t.key===`Enter`&&e.onSearch()})]}),(0,N.jsxs)(J,{children:[`Add filter`,(0,N.jsx)(Y,{id:`onboardingFilterKind`,value:e.onboardingFilterKind,disabled:e.onboardingFilterKeys.length===0,onChange:t=>e.setOnboardingFilterKind(t.target.value),children:e.onboardingFilterKeys.map(e=>(0,N.jsx)(`option`,{value:e,children:In[e].label},e))})]}),(0,N.jsxs)(J,{children:[`Value`,(0,N.jsx)(Y,{id:`onboardingFilterValue`,value:e.onboardingFilterValue,onChange:t=>e.setOnboardingFilterValue(t.target.value),children:t.map(([e,t])=>(0,N.jsx)(`option`,{value:e,children:t},e))})]}),(0,N.jsx)(U,{id:`addOnboardingFilter`,type:`button`,onClick:e.addFilter,disabled:e.onboardingFilterKeys.length===0,children:`Add filter`}),(0,N.jsx)(`div`,{id:`activeOnboardingFilters`,className:`md:col-span-5`,children:(0,N.jsx)(rr,{filters:e.onboardingFilters,onRemove:e.removeFilter,suffix:`onboarding filter`})})]}),(0,N.jsx)(pe,{hidden:e.people.length!==0,children:`No prospects match this queue view.`}),(0,N.jsx)(`div`,{className:`overflow-x-auto`,children:(0,N.jsxs)(Ht,{id:`onboardingTable`,className:Vt(`min-w-[1340px]`,e.people.length===0&&`hidden`),"aria-label":`Onboarding queue`,children:[(0,N.jsx)(Ut,{children:(0,N.jsxs)(Gt,{children:[(0,N.jsx)(B,{className:`w-[18%]`,label:`Name`,scope:`onboarding`,sort:e.sort,sortKey:`name`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[12%]`,label:`Status`,scope:`onboarding`,sort:e.sort,sortKey:`onboarding_state`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[20%]`,label:`Onboarder`,scope:`onboarding`,sort:e.sort,sortKey:`onboarder`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[12%]`,label:`Updated`,scope:`onboarding`,sort:e.sort,sortKey:`updated`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(L,{className:`w-[15%]`,children:`Email`}),(0,N.jsx)(L,{className:`w-[12%]`,children:`Links`}),(0,N.jsx)(B,{className:`w-[11%]`,label:`Needs`,scope:`onboarding`,sort:e.sort,sortKey:`profile_gaps`,onSort:(t,n)=>e.onSort(n)})]})}),(0,N.jsx)(Wt,{id:`onboardingBody`,children:e.people.map(t=>(0,N.jsx)(Ur,{person:t,loading:e.loading,canWrite:e.canWrite,onAssign:e.onAssign,onStatusChange:e.onStatusChange,onDraftEmail:e.onDraftEmail,onSendEmail:e.onSendEmail,crmContactUrl:e.crmContactUrl,crmAttachmentUrl:e.crmAttachmentUrl,canConfigure:e.canConfigure,onOpenConfiguration:e.onOpenConfiguration},t.crm_contact_id||t.latest_intake_submission?.submission_id||t.email||t.name))})]})})]})]})}var Nr=[`Female`,`Genderqueer`,`Male`,`Non-Conforming`,`Other`,`Prefer not to say`,`Transgender`],Pr=[`Company Email`,`Personal Email`,`User ID`];function Fr(e){let t=(e||``).trim().split(/\s+/).filter(Boolean);return t.length===0?{first:``,middle:``,last:``}:t.length===1?{first:t[0],middle:``,last:``}:t.length===2?{first:t[0],middle:``,last:t[1]}:{first:t[0],middle:t.slice(1,-1).join(` `),last:t[t.length-1]}}function Ir(e){let t=(e.email||``).trim();return!t||t.toLowerCase().endsWith(`@508.dev`)?``:t}function Lr(e){let t=(e.email_508||``).trim();if(t)return t;let n=(e.email||``).trim();return n.toLowerCase().endsWith(`@508.dev`)?n:``}function Rr(e,t){let n=e?.normalized_payload?.[t];return n==null?``:typeof n==`string`?n.trim():typeof n==`number`||typeof n==`boolean`?String(n):Array.isArray(n)?n.map(e=>String(e)).join(`, `):``}function zr(e){let t=Rr(e,`resume_url`);return/^https:\/\//i.test(t)?t:``}function Br(e){let t=Rr(e,`resume_file_name`);if(t)return t;let n=zr(e);if(!n)return``;try{let e=new URL(n).pathname;return decodeURIComponent(e.split(`/`).filter(Boolean).pop()||`Resume`)}catch{return`Resume`}}function Vr(e){return e?.normalized_payload?[[`Native name`,Rr(e,`native_name`)],[`Weekly hours`,Rr(e,`ideal_weekly_hours`)],[`Chat times`,Rr(e,`chat_availability`)||Rr(e,`availability`)],[`Rate`,Rr(e,`rate_range`)],[`Interest`,Rr(e,`top_question_about_508`)],[`Skills/interests`,Rr(e,`primary_skills_interests`)]].filter(([,e])=>e):[]}function Hr({loading:e,onSetup:t}){let[n,r]=(0,l.useState)(``),[i,a]=(0,l.useState)([]),[o,s]=(0,l.useState)(!1),[c,u]=(0,l.useState)(``),[d,f]=(0,l.useState)(``),[p,m]=(0,l.useState)(``),[h,g]=(0,l.useState)(``),[_,v]=(0,l.useState)(``),[y,b]=(0,l.useState)(``),[x,S]=(0,l.useState)(``),[ee,C]=(0,l.useState)(``),[w,te]=(0,l.useState)(``),[ne,re]=(0,l.useState)(``),[ie,ae]=(0,l.useState)(``);function oe(e){let t=Fr(e.name);m(t.first),g(t.middle),v(t.last),f(Lr(e)),re(Ir(e)),b(e.address_country||``),r(e.name||e.email_508||e.email||``),a([]),u(``)}async function E(){let e=n.trim();if(e){s(!0),u(``);try{a(await X(`/dashboard/api/people?${new URLSearchParams({limit:`8`,query:e}).toString()}`))}catch(e){u(Un(e,`Unable to search people`)),a([])}finally{s(!1)}}}async function O(){let e={email:d,first_name:p,middle_name:h,last_name:_,country:y,personal_email:ne};x.trim()&&(e.gender=x),ee.trim()&&(e.date_of_birth=ee),w.trim()&&(e.date_of_joining=w),ie.trim()&&(e.prefered_email=ie),await t(e)&&(r(``),a([]),f(``),m(``),g(``),v(``),b(``),S(``),C(``),te(``),re(``),ae(``))}return(0,N.jsxs)(W,{children:[(0,N.jsx)(G,{children:(0,N.jsx)(K,{children:`Engineer setup`})}),(0,N.jsx)(Xt,{children:(0,N.jsxs)(`form`,{className:`grid gap-3`,onSubmit:e=>{e.preventDefault(),O()},children:[(0,N.jsxs)(`div`,{className:`grid gap-3 border-b pb-3 md:grid-cols-[minmax(0,1fr)_auto]`,children:[(0,N.jsxs)(J,{children:[`CRM person`,(0,N.jsx)(q,{value:n,autoComplete:`off`,placeholder:`Search name or email`,onChange:e=>r(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),E())}})]}),(0,N.jsxs)(U,{type:`button`,onClick:E,disabled:o||!n.trim(),children:[(0,N.jsx)(T,{}),`Search`]}),c?(0,N.jsx)(`span`,{className:`text-sm font-semibold text-destructive`,children:c}):null,i.length>0?(0,N.jsx)(`div`,{className:`grid gap-2 md:col-span-2`,children:i.map(e=>{let t=e.name||e.email_508||e.email||e.crm_contact_id,n=[e.email_508||e.email,e.contact_type].filter(Boolean).join(` | `);return(0,N.jsxs)(`button`,{type:`button`,className:`grid rounded-md border bg-background px-3 py-2 text-left text-sm hover:border-primary`,onClick:()=>oe(e),children:[(0,N.jsx)(`strong`,{children:t}),n?(0,N.jsx)(`span`,{className:`text-muted-foreground`,children:n}):null]},e.crm_contact_id||t)})}):null]}),(0,N.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(130px,.6fr)]`,children:[(0,N.jsxs)(J,{children:[`Company email`,(0,N.jsx)(q,{value:d,autoComplete:`off`,placeholder:`engineer@508.dev`,onChange:e=>f(e.target.value)})]}),(0,N.jsxs)(J,{children:[`First name`,(0,N.jsx)(q,{value:p,autoComplete:`off`,placeholder:`First`,onChange:e=>m(e.target.value)})]}),(0,N.jsxs)(J,{children:[`Middle name`,(0,N.jsx)(q,{value:h,autoComplete:`off`,placeholder:`Optional`,onChange:e=>g(e.target.value)})]})]}),(0,N.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(130px,.6fr)]`,children:[(0,N.jsxs)(J,{children:[`Last name`,(0,N.jsx)(q,{value:_,autoComplete:`off`,placeholder:`Last`,onChange:e=>v(e.target.value)})]}),(0,N.jsxs)(J,{children:[`Country`,(0,N.jsx)(q,{value:y,autoComplete:`off`,placeholder:`Taiwan`,onChange:e=>b(e.target.value)})]})]}),(0,N.jsxs)(`details`,{className:`rounded-md border bg-background p-3`,children:[(0,N.jsx)(`summary`,{className:`cursor-pointer text-sm font-extrabold`,children:`Advanced options`}),(0,N.jsxs)(`div`,{className:`mt-3 grid gap-3 md:grid-cols-2`,children:[(0,N.jsxs)(J,{children:[`Gender`,(0,N.jsxs)(Y,{value:x,onChange:e=>S(e.target.value),children:[(0,N.jsx)(`option`,{value:``,children:`Default`}),Nr.map(e=>(0,N.jsx)(`option`,{value:e,children:e},e))]})]}),(0,N.jsxs)(J,{children:[`Date of birth`,(0,N.jsx)(q,{value:ee,type:`date`,autoComplete:`off`,onChange:e=>C(e.target.value)})]}),(0,N.jsxs)(J,{children:[`Date of joining`,(0,N.jsx)(q,{value:w,type:`date`,autoComplete:`off`,onChange:e=>te(e.target.value)})]}),(0,N.jsxs)(J,{children:[`Personal email`,(0,N.jsx)(q,{value:ne,type:`email`,autoComplete:`off`,placeholder:`Optional`,onChange:e=>re(e.target.value)})]}),(0,N.jsxs)(J,{children:[`Preferred contact email`,(0,N.jsxs)(Y,{value:ie,onChange:e=>ae(e.target.value),children:[(0,N.jsx)(`option`,{value:``,children:`Default`}),Pr.map(e=>(0,N.jsx)(`option`,{value:e,children:e},e))]})]})]})]}),(0,N.jsx)(`div`,{className:`flex flex-wrap items-center justify-between gap-3`,children:(0,N.jsxs)(U,{id:`setupEngineer`,type:`submit`,disabled:e||!d.trim()||!p.trim(),children:[(0,N.jsx)(D,{}),`Set up engineer`]})})]})})]})}function Ur({person:e,loading:t,canWrite:n,onAssign:r,onStatusChange:i,onDraftEmail:a,onSendEmail:o,crmContactUrl:s,crmAttachmentUrl:c,canConfigure:u,onOpenConfiguration:d}){let f=e.name||e.email_508||e.email||`CRM contact`,[p,m]=(0,l.useState)(sn(e.onboarder)),[h,g]=(0,l.useState)(!1),[_,v]=(0,l.useState)(null),[y,b]=(0,l.useState)(null),[x,S]=(0,l.useState)({has_contributed:Bn(rn(e))===`onboarded`,discord_joined:e.discord_user_id?`yes`:`unknown`,agreement_signed:`unknown`});(0,l.useEffect)(()=>m(sn(e.onboarder)),[e.onboarder]);let ee=Bn(rn(e)),C=!!e.crm_contact_id,te=e.profile_status||{},T=[[`Discord`,te.discord_linked],[`Resume`,te.latest_resume],[`Skills`,Number(te.skills_count||0)>0]].filter(([,e])=>!e),ae=s(e.crm_contact_id),oe=c(e.latest_resume_id),E=e.latest_intake_submission,D=e.latest_resume_intake_submission||E,O=zr(D),k=oe||O,se=oe?`Resume`:Br(D)||e.latest_resume_name||`Resume`,ce=Vr(E),le=_?.onboarding_email_sent_at||e.onboarding_email_sent_at,ue=_?.onboarding_email_sent_by||e.onboarding_email_sent_by,A=_?.onboarding_email_recipient||e.onboarding_email_recipient,j=!_||y!==null&&y.has_contributed===x.has_contributed&&y.discord_joined===x.discord_joined&&y.agreement_signed===x.agreement_signed,M=_&&!_.onboarding_email_sent_at?j?_.can_send?``:_.recipient_email?_.reply_to_email?`Send disabled: onboarding email SMTP is not configured.`:`Send disabled: your Reply-To email is missing.`:`Send disabled: candidate email is missing.`:`Send disabled: regenerate after changing draft options.`:``,de=M.includes(`SMTP`),fe=!!t[`onboarding-email-draft:${e.crm_contact_id}`],pe=!!t[`onboarding-email-send:${e.crm_contact_id}`],me=_?.markdown_body||``;async function he(t=x){let n=await a(e.crm_contact_id,t);n&&(v(n),b({...t}),g(!0))}async function ge(){if(!_||!y||!j)return;let t=await o(e.crm_contact_id,y,_.markdown_body);t&&v(t)}return(0,N.jsxs)(N.Fragment,{children:[(0,N.jsxs)(Gt,{children:[(0,N.jsxs)(R,{children:[ae?(0,N.jsx)(`a`,{className:`font-extrabold text-primary`,href:ae,target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${f} in CRM`,children:f}):(0,N.jsx)(`strong`,{children:f}),(0,N.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:e.email_508||e.email||``}),C?null:(0,N.jsx)(`div`,{className:`mt-1`,children:(0,N.jsx)(H,{variant:`missing`,children:`Application only`})}),E?(0,N.jsxs)(`details`,{className:`mt-2 rounded-md border bg-secondary/30 px-2 py-1 text-xs`,children:[(0,N.jsxs)(`summary`,{className:`cursor-pointer font-extrabold`,children:[`Application`,E.source?` via ${E.source}`:``,E.submitted_at?` | ${Qt(E.submitted_at)}`:``]}),(0,N.jsxs)(`div`,{className:`mt-2 grid gap-1 text-muted-foreground`,children:[ce.length>0?ce.map(([e,t])=>(0,N.jsxs)(`span`,{children:[(0,N.jsxs)(`strong`,{className:`text-foreground`,children:[e,`:`]}),` `,t]},e)):(0,N.jsx)(`span`,{children:`No extra application fields.`}),E.submission_id?(0,N.jsxs)(`span`,{children:[`Submission `,E.submission_id]}):null]})]}):null]}),(0,N.jsx)(R,{children:(0,N.jsxs)(`div`,{className:`grid max-w-56 gap-2`,children:[(0,N.jsx)(H,{variant:on(rn(e)),children:e.onboarding_status_label||an(rn(e))}),n&&C?(0,N.jsxs)(Y,{"aria-label":`Onboarding status for ${f}`,value:ee,disabled:t[`onboarding-status:${e.crm_contact_id}`],onChange:t=>i(e.crm_contact_id,t.target.value),children:[ee?null:(0,N.jsx)(`option`,{value:``,disabled:!0,children:`No status`}),Ln.map(([e,t])=>(0,N.jsx)(`option`,{value:e,children:t},e))]}):null]})}),(0,N.jsx)(R,{children:(0,N.jsxs)(`form`,{className:`grid max-w-64 grid-cols-[minmax(100px,1fr)_auto] items-center gap-2`,onSubmit:t=>{t.preventDefault(),r(e.crm_contact_id,p)},children:[(0,N.jsx)(q,{"aria-label":`Onboarder for ${f}`,value:p,placeholder:`508 username`,disabled:!C,onChange:e=>m(e.target.value)}),(0,N.jsx)(U,{type:`submit`,size:`sm`,"aria-label":`Save onboarder for ${f}`,disabled:!C||t[`onboarder:${e.crm_contact_id}`],children:`Save`})]})}),(0,N.jsx)(R,{children:Qt(e.onboarding_updated_at)}),(0,N.jsx)(R,{children:(0,N.jsxs)(`div`,{className:`grid gap-2`,children:[le?(0,N.jsxs)(H,{variant:`succeeded`,children:[`Sent `,Qt(le)]}):(0,N.jsx)(H,{variant:`neutral`,children:`Not sent`}),A?(0,N.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:A}):null,ue?(0,N.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[`By `,ue]}):null,n&&C?(0,N.jsxs)(U,{type:`button`,size:`sm`,variant:h?`outline`:`secondary`,onClick:()=>{if(h){g(!1);return}g(!0),_||he()},disabled:fe,children:[(0,N.jsx)(w,{}),_?`Edit draft`:`Draft email`]}):null]})}),(0,N.jsx)(R,{children:(0,N.jsxs)(`div`,{className:`flex flex-wrap gap-1.5`,children:[k?(0,N.jsx)(`a`,{className:`inline-flex min-h-7 max-w-40 items-center truncate rounded-md border bg-secondary px-2 text-xs font-extrabold`,href:k,target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${f} resume`,title:se,children:se}):null,fn(e.linkedin)?(0,N.jsx)(`a`,{className:`inline-flex min-h-7 items-center rounded-md border bg-secondary px-2 text-xs font-extrabold`,href:fn(e.linkedin),target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${f} LinkedIn`,children:`LinkedIn`}):null,pn(e.github_username)?(0,N.jsx)(`a`,{className:`inline-flex min-h-7 items-center rounded-md border bg-secondary px-2 text-xs font-extrabold`,href:pn(e.github_username),target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${f} GitHub`,children:e.github_username||`GitHub`}):null,!k&&!fn(e.linkedin)&&!pn(e.github_username)?`None`:null]})}),(0,N.jsx)(R,{children:(0,N.jsxs)(`div`,{className:`flex flex-wrap gap-1.5`,children:[T.map(([e])=>(0,N.jsxs)(H,{variant:`missing`,children:[`Missing `,e]},String(e))),T.length===0?`None`:null]})})]}),h?(0,N.jsx)(Gt,{children:(0,N.jsx)(R,{colSpan:7,className:`bg-secondary/30`,children:(0,N.jsxs)(`div`,{className:`grid gap-3 rounded-md border bg-background p-4`,children:[(0,N.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-[auto_minmax(150px,220px)_minmax(150px,220px)_auto] md:items-end`,children:[(0,N.jsxs)(`label`,{className:`flex min-h-9 items-center gap-2 text-sm font-semibold`,children:[(0,N.jsx)(`input`,{type:`checkbox`,checked:x.has_contributed,onChange:e=>{S({...x,has_contributed:e.target.checked})}}),`Contribution done`]}),(0,N.jsxs)(J,{children:[`Discord`,(0,N.jsxs)(Y,{value:x.discord_joined,onChange:e=>S({...x,discord_joined:e.target.value}),children:[(0,N.jsx)(`option`,{value:`unknown`,children:`Unknown`}),(0,N.jsx)(`option`,{value:`yes`,children:`Joined`}),(0,N.jsx)(`option`,{value:`no`,children:`Not joined`})]})]}),(0,N.jsxs)(J,{children:[`Agreement`,(0,N.jsxs)(Y,{value:x.agreement_signed,onChange:e=>S({...x,agreement_signed:e.target.value}),children:[(0,N.jsx)(`option`,{value:`unknown`,children:`Unknown`}),(0,N.jsx)(`option`,{value:`yes`,children:`Signed`}),(0,N.jsx)(`option`,{value:`no`,children:`Not signed`})]})]}),(0,N.jsxs)(U,{type:`button`,variant:`outline`,onClick:()=>he(),disabled:fe,children:[(0,N.jsx)(ne,{}),`Regenerate`]})]}),_?(0,N.jsxs)(N.Fragment,{children:[(0,N.jsxs)(`div`,{className:`grid gap-2 text-sm md:grid-cols-4`,children:[(0,N.jsxs)(`span`,{children:[(0,N.jsx)(`strong`,{children:`To:`}),` `,_.recipient_email||`Missing`]}),(0,N.jsxs)(`span`,{children:[(0,N.jsx)(`strong`,{children:`Reply-To:`}),` `,_.reply_to_email||`Missing`]}),(0,N.jsxs)(`span`,{children:[(0,N.jsx)(`strong`,{children:`Cc:`}),` `,_.cc_email||`Missing`]}),(0,N.jsxs)(`span`,{children:[(0,N.jsx)(`strong`,{children:`From:`}),` `,_.sender_display_name||`onboarding`]})]}),(0,N.jsxs)(J,{children:[`Subject`,(0,N.jsx)(q,{value:_.subject,readOnly:!0})]}),(0,N.jsxs)(J,{children:[`Draft`,(0,N.jsx)(`textarea`,{value:me,className:`min-h-64 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]`,onChange:e=>v({..._,markdown_body:e.target.value})})]}),(0,N.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[(0,N.jsxs)(U,{type:`button`,variant:`default`,onClick:ge,disabled:pe||!_.can_send||!j||!me.trim(),title:M||void 0,children:[(0,N.jsx)(re,{}),pe?`Sending`:`Send`]}),M?(0,N.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[M,de&&u?(0,N.jsxs)(U,{type:`button`,size:`sm`,variant:`ghost`,className:`ml-2`,onClick:d,children:[(0,N.jsx)(ie,{}),`Configure`]}):null]}):null,_.marker_status===`error`?(0,N.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[`Marker not saved: `,_.marker_error||`unknown`]}):null]})]}):(0,N.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:fe?`Drafting email`:`No draft loaded`})]})})}):null]})}function Wr(e){return(0,N.jsxs)(N.Fragment,{children:[(0,N.jsxs)(W,{className:`grid gap-3 p-4 md:grid-cols-4 md:items-end`,children:[(0,N.jsxs)(J,{children:[`Window`,(0,N.jsxs)(Y,{id:`minutes`,value:e.minutes,onChange:t=>e.setMinutes(t.target.value),children:[(0,N.jsx)(`option`,{value:`15`,children:`15 minutes`}),(0,N.jsx)(`option`,{value:`60`,children:`1 hour`}),(0,N.jsx)(`option`,{value:`360`,children:`6 hours`}),(0,N.jsx)(`option`,{value:`1440`,children:`24 hours`})]})]}),(0,N.jsxs)(J,{children:[`Status`,(0,N.jsxs)(Y,{id:`status`,value:e.status,onChange:t=>e.setStatus(t.target.value),children:[(0,N.jsx)(`option`,{value:``,children:`Any status`}),(0,N.jsx)(`option`,{value:`queued`,children:`Queued`}),(0,N.jsx)(`option`,{value:`running`,children:`Running`}),(0,N.jsx)(`option`,{value:`succeeded`,children:`Succeeded`}),(0,N.jsx)(`option`,{value:`failed`,children:`Failed`}),(0,N.jsx)(`option`,{value:`dead`,children:`Dead`}),(0,N.jsx)(`option`,{value:`canceled`,children:`Canceled`})]})]}),(0,N.jsxs)(J,{children:[`Type`,(0,N.jsx)(q,{id:`jobType`,value:e.jobType,autoComplete:`off`,placeholder:`Any type`,onChange:t=>e.setJobType(t.target.value),onKeyDown:t=>t.key===`Enter`&&e.onSearch()})]}),(0,N.jsxs)(U,{id:`refreshJobs`,type:`button`,onClick:e.onSearch,disabled:e.loading.jobs,children:[(0,N.jsx)(ne,{}),`Refresh background tasks`]})]}),(0,N.jsxs)(`section`,{className:`grid gap-3 md:grid-cols-4`,"aria-label":`Background task summary`,children:[(0,N.jsx)(Zn,{id:`metricTotal`,label:`Total`,value:e.jobs.length}),(0,N.jsx)(Zn,{id:`metricQueued`,label:`Queued`,value:e.jobCounts.queued||0}),(0,N.jsx)(Zn,{id:`metricRunning`,label:`Running`,value:e.jobCounts.running||0}),(0,N.jsx)(Zn,{id:`metricFailed`,label:`Failed`,value:(e.jobCounts.failed||0)+(e.jobCounts.dead||0)})]}),(0,N.jsxs)(W,{children:[(0,N.jsx)(G,{children:(0,N.jsx)(K,{children:`Recent background tasks`})}),(0,N.jsx)(pe,{hidden:e.jobs.length!==0,children:`No background tasks match these filters.`}),(0,N.jsx)(`div`,{className:`overflow-x-auto`,children:(0,N.jsxs)(Ht,{id:`jobsTable`,className:Vt(`min-w-[980px]`,e.jobs.length===0&&`hidden`),"aria-label":`Recent background tasks`,children:[(0,N.jsx)(Ut,{children:(0,N.jsxs)(Gt,{children:[(0,N.jsx)(B,{className:`w-[22%]`,label:`Task id`,scope:`jobs`,sort:e.sort,sortKey:`job_id`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[24%]`,label:`Task type`,scope:`jobs`,sort:e.sort,sortKey:`type`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[12%]`,label:`Status`,scope:`jobs`,sort:e.sort,sortKey:`status`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[12%]`,label:`Attempts`,scope:`jobs`,sort:e.sort,sortKey:`attempts`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[18%]`,label:`Updated`,scope:`jobs`,sort:e.sort,sortKey:`updated_at`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(L,{children:`Actions`})]})}),(0,N.jsx)(Wt,{id:`jobsBody`,children:e.jobs.map(t=>(0,N.jsxs)(Gt,{children:[(0,N.jsx)(R,{className:`font-mono`,children:t.job_id}),(0,N.jsx)(R,{children:t.type}),(0,N.jsx)(R,{children:(0,N.jsx)(H,{variant:t.status||`neutral`,children:t.status})}),(0,N.jsxs)(R,{children:[t.attempts,`/`,t.max_attempts]}),(0,N.jsx)(R,{children:Qt(t.updated_at)}),(0,N.jsx)(R,{children:(0,N.jsxs)(`div`,{className:`flex flex-wrap justify-end gap-2`,children:[(0,N.jsx)(U,{type:`button`,size:`sm`,variant:`outline`,"aria-label":`View details for ${t.type} task ${t.job_id}`,onClick:()=>e.onDetail(t.job_id),disabled:e.loading[`detail:${t.job_id}`],children:`Details`}),e.canWrite?(0,N.jsx)(U,{type:`button`,size:`sm`,"aria-label":`Rerun ${t.type} task ${t.job_id}`,onClick:()=>e.onRerun(t.job_id),disabled:e.loading[`rerun:${t.job_id}`],children:`Rerun`}):null]})})]},t.job_id))})]})})]}),e.jobDetail?(0,N.jsxs)(W,{id:`jobDetailPanel`,children:[(0,N.jsxs)(G,{children:[(0,N.jsx)(K,{children:`Task detail`}),(0,N.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:e.jobDetail.job_id})]}),(0,N.jsxs)(Xt,{className:`grid gap-4`,children:[(0,N.jsx)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[[`Task type`,e.jobDetail.type],[`Status`,e.jobDetail.status],[`Attempts`,`${e.jobDetail.attempts}/${e.jobDetail.max_attempts}`],[`Updated`,Qt(e.jobDetail.updated_at)],[`Created`,Qt(e.jobDetail.created_at)],[`Run after`,Qt(e.jobDetail.run_after)],[`Locked by`,e.jobDetail.locked_by||`None`],[`Last error`,e.jobDetail.last_error||`None`]].map(([e,t])=>(0,N.jsxs)(`div`,{className:`grid gap-1 rounded-md border bg-background p-3`,children:[(0,N.jsx)(`span`,{className:`text-[11px] font-extrabold uppercase text-muted-foreground`,children:e}),(0,N.jsx)(`strong`,{className:`break-words text-sm`,children:t})]},e))}),(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`h2`,{className:`mb-2 text-[15px] font-bold`,children:`Payload`}),(0,N.jsx)(`pre`,{className:`max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md border bg-background p-3 font-mono text-xs`,children:en(e.jobDetail.payload)||`No payload`})]}),(0,N.jsxs)(`div`,{children:[(0,N.jsx)(`h2`,{className:`mb-2 text-[15px] font-bold`,children:`Result`}),(0,N.jsx)(`pre`,{className:`max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md border bg-background p-3 font-mono text-xs`,children:en(e.jobDetail.result)||`No result`})]})]})]}):null]})}function Gr(e){return(0,N.jsxs)(W,{children:[(0,N.jsxs)(G,{children:[(0,N.jsx)(K,{children:`Recent audit`}),(0,N.jsxs)(U,{id:`refreshAudit`,type:`button`,variant:`outline`,onClick:e.onRefresh,disabled:e.loading.audit,children:[(0,N.jsx)(ne,{}),`Refresh`]})]}),(0,N.jsx)(pe,{hidden:e.events.length!==0,children:`No audit events found.`}),(0,N.jsx)(`div`,{className:`overflow-x-auto`,children:(0,N.jsxs)(Ht,{id:`auditTable`,className:Vt(`min-w-[760px]`,e.events.length===0&&`hidden`),"aria-label":`Recent audit events`,children:[(0,N.jsx)(Ut,{children:(0,N.jsxs)(Gt,{children:[(0,N.jsx)(B,{className:`w-[24%]`,label:`Time`,scope:`audit`,sort:e.sort,sortKey:`occurred_at`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[28%]`,label:`Actor`,scope:`audit`,sort:e.sort,sortKey:`actor`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[28%]`,label:`Action`,scope:`audit`,sort:e.sort,sortKey:`action`,onSort:(t,n)=>e.onSort(n)}),(0,N.jsx)(B,{className:`w-[20%]`,label:`Result`,scope:`audit`,sort:e.sort,sortKey:`result`,onSort:(t,n)=>e.onSort(n)})]})}),(0,N.jsx)(Wt,{id:`auditBody`,children:e.events.map(e=>(0,N.jsxs)(Gt,{children:[(0,N.jsx)(R,{children:Qt(e.occurred_at)}),(0,N.jsx)(R,{children:e.actor_display_name||e.actor_subject||e.actor_provider}),(0,N.jsx)(R,{children:e.action}),(0,N.jsx)(R,{children:(0,N.jsx)(H,{variant:e.result===`success`?`succeeded`:`failed`,children:e.result})})]},e.id||`${e.occurred_at||``}-${e.actor_subject||``}-${e.action||``}`))})]})})]})}function Kr({report:e,loading:t,onRefresh:n}){let r=e?.summary||{},i=[[`Status`,e?.status_counts||{}],[`Intent`,e?.intent_counts||{}],[`Planner`,e?.planner_counts||{}],[`Model`,e?.model_counts||{}],[`Action`,e?.action_counts||{}],[`Tool outcome`,e?.tool_outcome_counts||{}]].flatMap(([e,t])=>Object.entries(t).map(([t,n])=>({label:e,value:t,count:n}))).sort((e,t)=>t.count-e.count||e.label.localeCompare(t.label)),a=Array.isArray(e?.recent_unsupported)?e.recent_unsupported:[];return(0,N.jsxs)(N.Fragment,{children:[(0,N.jsxs)(W,{children:[(0,N.jsxs)(G,{children:[(0,N.jsx)(K,{children:`Agent requests`}),(0,N.jsxs)(U,{id:`refreshAgent`,type:`button`,variant:`outline`,onClick:n,disabled:t.agent,children:[(0,N.jsx)(ne,{}),`Refresh`]})]}),(0,N.jsxs)(Xt,{className:`grid gap-3 md:grid-cols-5`,children:[(0,N.jsx)(Zn,{id:`agentMetricTotal`,label:`Total`,value:r.total||0}),(0,N.jsx)(Zn,{id:`agentMetricHandled`,label:`Handled`,value:r.handled||0}),(0,N.jsx)(Zn,{id:`agentMetricConfirmations`,label:`Confirmations`,value:r.requires_confirmation||0}),(0,N.jsx)(Zn,{id:`agentMetricClarifications`,label:`Clarifications`,value:r.needs_clarification||0}),(0,N.jsx)(Zn,{id:`agentMetricUnsupported`,label:`Not understood`,value:r.unsupported||0})]})]}),(0,N.jsxs)(W,{children:[(0,N.jsxs)(G,{children:[(0,N.jsx)(K,{children:`Request mix`}),(0,N.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:`Recent agent.request audit events.`})]}),(0,N.jsx)(pe,{hidden:i.length!==0,children:`No agent request data found.`}),(0,N.jsx)(`div`,{className:`overflow-x-auto`,children:(0,N.jsxs)(Ht,{id:`agentBreakdownTable`,className:Vt(`min-w-[860px]`,i.length===0&&`hidden`),"aria-label":`Agent request breakdown`,children:[(0,N.jsx)(Ut,{children:(0,N.jsxs)(Gt,{children:[(0,N.jsx)(L,{children:`Dimension`}),(0,N.jsx)(L,{children:`Value`}),(0,N.jsx)(L,{children:`Count`})]})}),(0,N.jsx)(Wt,{id:`agentBreakdownBody`,children:i.map(e=>(0,N.jsxs)(Gt,{children:[(0,N.jsx)(R,{children:e.label}),(0,N.jsx)(R,{children:e.value}),(0,N.jsx)(R,{children:e.count})]},`${e.label}-${e.value}`))})]})})]}),(0,N.jsxs)(W,{children:[(0,N.jsxs)(G,{children:[(0,N.jsx)(K,{children:`Not understood`}),(0,N.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:`Sanitized request text only.`})]}),(0,N.jsx)(pe,{hidden:a.length!==0,children:`No unsupported agent requests found.`}),(0,N.jsx)(`div`,{className:`overflow-x-auto`,children:(0,N.jsxs)(Ht,{id:`agentUnsupportedTable`,className:Vt(`min-w-[860px]`,a.length===0&&`hidden`),"aria-label":`Unsupported agent requests`,children:[(0,N.jsx)(Ut,{children:(0,N.jsxs)(Gt,{children:[(0,N.jsx)(L,{children:`Time`}),(0,N.jsx)(L,{children:`Actor`}),(0,N.jsx)(L,{children:`Message`}),(0,N.jsx)(L,{children:`Result`})]})}),(0,N.jsx)(Wt,{id:`agentUnsupportedBody`,children:a.map(e=>(0,N.jsxs)(Gt,{children:[(0,N.jsx)(R,{children:Qt(e.occurred_at)}),(0,N.jsx)(R,{children:e.actor}),(0,N.jsx)(R,{children:e.message_sanitized}),(0,N.jsx)(R,{children:(0,N.jsx)(H,{variant:e.result===`success`?`succeeded`:`failed`,children:e.result||`unknown`})})]},`${e.occurred_at||``}-${e.actor||``}-${e.message_sanitized||``}`))})]})})]})]})}var qr=document.getElementById(`root`);if(qr)(0,fe.createRoot)(qr).render((0,N.jsx)(l.StrictMode,{children:(0,N.jsx)($n,{})}));else throw Error(`Missing #root container`); \ No newline at end of file diff --git a/apps/api/src/five08/backend/static/dashboard/assets/index-C3gljehK.css b/apps/api/src/five08/backend/static/dashboard/assets/index-C3gljehK.css new file mode 100644 index 00000000..286b5468 --- /dev/null +++ b/apps/api/src/five08/backend/static/dashboard/assets/index-C3gljehK.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.3.1 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-100:oklch(93.6% .032 17.717);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-700:oklch(50.5% .213 27.518);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-teal-200:oklch(91% .096 180.426);--color-teal-400:oklch(77.7% .152 181.912);--color-teal-500:oklch(70.4% .14 182.503);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--leading-tight:1.25;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--border);outline-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){*{outline-color:color-mix(in oklab, var(--ring) 50%, transparent)}}body{min-width:calc(var(--spacing) * 80);background-color:var(--background);color:var(--foreground);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}button{cursor:pointer}button:disabled{cursor:not-allowed}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.-top-1{top:calc(var(--spacing) * -1)}.top-0{top:0}.-right-1{right:calc(var(--spacing) * -1)}.right-0{right:0}.right-5{right:calc(var(--spacing) * 5)}.bottom-5{bottom:calc(var(--spacing) * 5)}.left-5{left:calc(var(--spacing) * 5)}.z-20{z-index:20}.z-40{z-index:40}.z-50{z-index:50}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.m-0{margin:0}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.ml-2{margin-left:calc(var(--spacing) * 2)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.table{display:table}.table-cell{display:table-cell}.table-row{display:table-row}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-full{height:100%}.max-h-20{max-height:calc(var(--spacing) * 20)}.max-h-40{max-height:calc(var(--spacing) * 40)}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-56{max-height:calc(var(--spacing) * 56)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-\[78vh\]{max-height:78vh}.max-h-\[90vh\]{max-height:90vh}.min-h-0{min-height:0}.min-h-5{min-height:calc(var(--spacing) * 5)}.min-h-7{min-height:calc(var(--spacing) * 7)}.min-h-8{min-height:calc(var(--spacing) * 8)}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-10{min-height:calc(var(--spacing) * 10)}.min-h-11{min-height:calc(var(--spacing) * 11)}.min-h-20{min-height:calc(var(--spacing) * 20)}.min-h-64{min-height:calc(var(--spacing) * 64)}.min-h-\[22px\]{min-height:22px}.w-\[10\%\]{width:10%}.w-\[11\%\]{width:11%}.w-\[12\%\]{width:12%}.w-\[14\%\]{width:14%}.w-\[15\%\]{width:15%}.w-\[16\%\]{width:16%}.w-\[18\%\]{width:18%}.w-\[20\%\]{width:20%}.w-\[22\%\]{width:22%}.w-\[24\%\]{width:24%}.w-\[25\%\]{width:25%}.w-\[26\%\]{width:26%}.w-\[27\%\]{width:27%}.w-\[28\%\]{width:28%}.w-\[29\%\]{width:29%}.w-\[32\%\]{width:32%}.w-\[34\%\]{width:34%}.w-\[48px\]{width:48px}.w-\[160px\]{width:160px}.w-\[min\(48rem\,calc\(100vw-2\.5rem\)\)\]{width:min(48rem,100vw - 2.5rem)}.w-fit{width:fit-content}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-40{max-width:calc(var(--spacing) * 40)}.max-w-48{max-width:calc(var(--spacing) * 48)}.max-w-56{max-width:calc(var(--spacing) * 56)}.max-w-64{max-width:calc(var(--spacing) * 64)}.max-w-\[360px\]{max-width:360px}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.min-w-0{min-width:0}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-44{min-width:calc(var(--spacing) * 44)}.min-w-\[190px\]{min-width:190px}.min-w-\[760px\]{min-width:760px}.min-w-\[860px\]{min-width:860px}.min-w-\[900px\]{min-width:900px}.min-w-\[920px\]{min-width:920px}.min-w-\[980px\]{min-width:980px}.min-w-\[1100px\]{min-width:1100px}.min-w-\[1340px\]{min-width:1340px}.shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.scroll-mt-4{scroll-margin-top:calc(var(--spacing) * 4)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[minmax\(100px\,1fr\)_auto\]{grid-template-columns:minmax(100px,1fr) auto}.grid-rows-\[auto_minmax\(0\,1fr\)\]{grid-template-rows:auto minmax(0,1fr)}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.content-start{align-content:flex-start}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-y-1{row-gap:var(--spacing)}.self-end{align-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-400\/40{border-color:#fcbb0066}@supports (color:color-mix(in lab, red, red)){.border-amber-400\/40{border-color:color-mix(in oklab, var(--color-amber-400) 40%, transparent)}}.border-amber-500\/40{border-color:#f99c0066}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/40{border-color:color-mix(in oklab, var(--color-amber-500) 40%, transparent)}}.border-border{border-color:var(--border)}.border-destructive{border-color:var(--destructive)}.border-emerald-400\/35{border-color:#00d29459}@supports (color:color-mix(in lab, red, red)){.border-emerald-400\/35{border-color:color-mix(in oklab, var(--color-emerald-400) 35%, transparent)}}.border-emerald-500\/40{border-color:#00bb7f66}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/40{border-color:color-mix(in oklab, var(--color-emerald-500) 40%, transparent)}}.border-input{border-color:var(--input)}.border-primary{border-color:var(--primary)}.border-red-400\/40{border-color:#ff656866}@supports (color:color-mix(in lab, red, red)){.border-red-400\/40{border-color:color-mix(in oklab, var(--color-red-400) 40%, transparent)}}.border-red-500\/25{border-color:#fb2c3640}@supports (color:color-mix(in lab, red, red)){.border-red-500\/25{border-color:color-mix(in oklab, var(--color-red-500) 25%, transparent)}}.border-red-500\/40{border-color:#fb2c3666}@supports (color:color-mix(in lab, red, red)){.border-red-500\/40{border-color:color-mix(in oklab, var(--color-red-500) 40%, transparent)}}.border-teal-400\/40{border-color:#00d3bd66}@supports (color:color-mix(in lab, red, red)){.border-teal-400\/40{border-color:color-mix(in oklab, var(--color-teal-400) 40%, transparent)}}.border-transparent{border-color:#0000}.border-l-muted-foreground\/60{border-left-color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){.border-l-muted-foreground\/60{border-left-color:color-mix(in oklab, var(--muted-foreground) 60%, transparent)}}.bg-accent{background-color:var(--accent)}.bg-amber-200{background-color:var(--color-amber-200)}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-amber-500\/15{background-color:#f99c0026}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/15{background-color:color-mix(in oklab, var(--color-amber-500) 15%, transparent)}}.bg-background,.bg-background\/40{background-color:var(--background)}@supports (color:color-mix(in lab, red, red)){.bg-background\/40{background-color:color-mix(in oklab, var(--background) 40%, transparent)}}.bg-background\/90{background-color:var(--background)}@supports (color:color-mix(in lab, red, red)){.bg-background\/90{background-color:color-mix(in oklab, var(--background) 90%, transparent)}}.bg-black\/20{background-color:#0003}@supports (color:color-mix(in lab, red, red)){.bg-black\/20{background-color:color-mix(in oklab, var(--color-black) 20%, transparent)}}.bg-black\/30{background-color:#0000004d}@supports (color:color-mix(in lab, red, red)){.bg-black\/30{background-color:color-mix(in oklab, var(--color-black) 30%, transparent)}}.bg-black\/45{background-color:#00000073}@supports (color:color-mix(in lab, red, red)){.bg-black\/45{background-color:color-mix(in oklab, var(--color-black) 45%, transparent)}}.bg-card{background-color:var(--card)}.bg-destructive{background-color:var(--destructive)}.bg-emerald-500\/15{background-color:#00bb7f26}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/15{background-color:color-mix(in oklab, var(--color-emerald-500) 15%, transparent)}}.bg-primary{background-color:var(--primary)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/5{background-color:#fb2c360d}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/5{background-color:color-mix(in oklab, var(--color-red-500) 5%, transparent)}}.bg-red-500\/15{background-color:#fb2c3626}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/15{background-color:color-mix(in oklab, var(--color-red-500) 15%, transparent)}}.bg-secondary,.bg-secondary\/30{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.bg-secondary\/30{background-color:color-mix(in oklab, var(--secondary) 30%, transparent)}}.bg-secondary\/35{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.bg-secondary\/35{background-color:color-mix(in oklab, var(--secondary) 35%, transparent)}}.bg-secondary\/45{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.bg-secondary\/45{background-color:color-mix(in oklab, var(--secondary) 45%, transparent)}}.bg-teal-500\/15{background-color:#00baa726}@supports (color:color-mix(in lab, red, red)){.bg-teal-500\/15{background-color:color-mix(in oklab, var(--color-teal-500) 15%, transparent)}}.p-0{padding:0}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:var(--spacing)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-7{padding-block:calc(var(--spacing) * 7)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-\[inherit\]{font-family:inherit}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[11px\]{font-size:11px}.text-\[15px\]{font-size:15px}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-accent-foreground{color:var(--accent-foreground)}.text-amber-200{color:var(--color-amber-200)}.text-amber-300{color:var(--color-amber-300)}.text-card-foreground{color:var(--card-foreground)}.text-destructive{color:var(--destructive)}.text-emerald-300{color:var(--color-emerald-300)}.text-foreground{color:var(--foreground)}.text-inherit{color:inherit}.text-muted-foreground{color:var(--muted-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-red-100{color:var(--color-red-100)}.text-red-300{color:var(--color-red-300)}.text-red-700{color:var(--color-red-700)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-teal-200{color:var(--color-teal-200)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.underline-offset-4{text-underline-offset:4px}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_18px_44px_rgb\(0_0_0\/0\.22\)\]{--tw-shadow:0 18px 44px var(--tw-shadow-color,#00000038);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-primary{--tw-ring-color:var(--primary)}.ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ring-offset-background{--tw-ring-offset-color:var(--background)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.running{animation-play-state:running}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}@media (hover:hover){.hover\:border-border:hover{border-color:var(--border)}.hover\:border-primary:hover{border-color:var(--primary)}.hover\:bg-accent:hover{background-color:var(--accent)}.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab, var(--destructive) 90%, transparent)}}.hover\:bg-muted\/45:hover{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-muted\/45:hover{background-color:color-mix(in oklab, var(--muted) 45%, transparent)}}.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab, var(--primary) 90%, transparent)}}.hover\:bg-secondary:hover,.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab, var(--secondary) 80%, transparent)}}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:bg-secondary:focus{background-color:var(--secondary)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (width>=40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (width>=48rem){.md\:sticky{position:sticky}.md\:top-24{top:calc(var(--spacing) * 24)}.md\:col-span-2{grid-column:span 2/span 2}.md\:col-span-4{grid-column:span 4/span 4}.md\:col-span-5{grid-column:span 5/span 5}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.md\:grid-cols-\[7rem_minmax\(0\,1fr\)\]{grid-template-columns:7rem minmax(0,1fr)}.md\:grid-cols-\[190px_minmax\(0\,1fr\)\]{grid-template-columns:190px minmax(0,1fr)}.md\:grid-cols-\[auto_minmax\(0\,1fr\)_auto\]{grid-template-columns:auto minmax(0,1fr) auto}.md\:grid-cols-\[auto_minmax\(150px\,220px\)_minmax\(150px\,220px\)_auto\]{grid-template-columns:auto minmax(150px,220px) minmax(150px,220px) auto}.md\:grid-cols-\[minmax\(0\,1fr\)_180px_auto_auto_auto\]{grid-template-columns:minmax(0,1fr) 180px auto auto auto}.md\:grid-cols-\[minmax\(0\,1fr\)_auto\]{grid-template-columns:minmax(0,1fr) auto}.md\:grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1fr\)_minmax\(130px\,\.6fr\)\]{grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(130px,.6fr)}.md\:grid-cols-\[minmax\(0\,1fr\)_minmax\(130px\,\.6fr\)\]{grid-template-columns:minmax(0,1fr) minmax(130px,.6fr)}.md\:grid-cols-\[minmax\(120px\,\.7fr\)_minmax\(150px\,1fr\)_minmax\(150px\,1fr\)_auto\]{grid-template-columns:minmax(120px,.7fr) minmax(150px,1fr) minmax(150px,1fr) auto}.md\:grid-cols-\[minmax\(140px\,\.8fr\)_minmax\(150px\,1fr\)_minmax\(150px\,1fr\)_minmax\(120px\,\.7fr\)_auto\]{grid-template-columns:minmax(140px,.8fr) minmax(150px,1fr) minmax(150px,1fr) minmax(120px,.7fr) auto}.md\:grid-cols-\[minmax\(140px\,\.75fr\)_minmax\(220px\,1\.25fr\)_auto_auto_auto\]{grid-template-columns:minmax(140px,.75fr) minmax(220px,1.25fr) auto auto auto}.md\:grid-cols-\[minmax\(160px\,\.6fr\)_auto_auto\]{grid-template-columns:minmax(160px,.6fr) auto auto}.md\:grid-cols-\[minmax\(220px\,1fr\)_auto\]{grid-template-columns:minmax(220px,1fr) auto}.md\:grid-cols-\[minmax\(260px\,1fr\)_minmax\(180px\,\.7fr\)_minmax\(130px\,\.45fr\)_minmax\(130px\,\.45fr\)_auto_auto\]{grid-template-columns:minmax(260px,1fr) minmax(180px,.7fr) minmax(130px,.45fr) minmax(130px,.45fr) auto auto}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:items-end{align-items:flex-end}.md\:items-start{align-items:flex-start}.md\:justify-between{justify-content:space-between}.md\:justify-end{justify-content:flex-end}}@media (width>=64rem){.lg\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[1fr_1fr_1fr\]{grid-template-columns:1fr 1fr 1fr}.lg\:grid-cols-\[minmax\(0\,1\.5fr\)_minmax\(180px\,0\.7fr\)_auto\]{grid-template-columns:minmax(0,1.5fr) minmax(180px,.7fr) auto}.lg\:grid-cols-\[minmax\(0\,1fr\)_220px_180px\]{grid-template-columns:minmax(0,1fr) 220px 180px}.lg\:grid-cols-\[minmax\(0\,1fr\)_220px_190px\]{grid-template-columns:minmax(0,1fr) 220px 190px}.lg\:items-start{align-items:flex-start}}@media (width>=80rem){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}.dark\:bg-amber-500\/35:is(.dark *){background-color:#f99c0059}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-500\/35:is(.dark *){background-color:color-mix(in oklab, var(--color-amber-500) 35%, transparent)}}.dark\:text-red-300:is(.dark *){color:var(--color-red-300)}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}:root{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--radius:.5rem;--background:oklch(16% .006 160);--foreground:oklch(94% .008 125);--card:oklch(22% .007 155);--card-foreground:oklch(94% .008 125);--popover:oklch(22% .007 155);--popover-foreground:oklch(94% .008 125);--primary:oklch(73% .11 178);--primary-foreground:oklch(17% .018 178);--secondary:oklch(28% .012 155);--secondary-foreground:oklch(94% .008 125);--muted:oklch(28% .012 155);--muted-foreground:oklch(71% .016 135);--accent:oklch(30% .042 178);--accent-foreground:oklch(88% .064 178);--destructive:oklch(67% .18 23);--border:oklch(100% 0 0/.14);--input:oklch(100% 0 0/.16);--ring:oklch(73% .11 178)}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false} diff --git a/apps/api/src/five08/backend/static/dashboard/assets/index-CjyViga_.js b/apps/api/src/five08/backend/static/dashboard/assets/index-CjyViga_.js deleted file mode 100644 index 2b9031be..00000000 --- a/apps/api/src/five08/backend/static/dashboard/assets/index-CjyViga_.js +++ /dev/null @@ -1,9 +0,0 @@ -var e=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports);(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var t=e((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var ee=Array.isArray;function te(){}var S={H:null,A:null,T:null,S:null},C=Object.prototype.hasOwnProperty;function w(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function ne(e,t){return w(e.type,t,e.props)}function re(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function ie(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var ae=/\/+/g;function oe(e,t){return typeof e==`object`&&e&&e.key!=null?ie(``+e.key):t.toString(36)}function se(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(te,te):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function T(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,T(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+oe(e,0):a,ee(o)?(i=``,c!=null&&(i=c.replace(ae,`$&/`)+`/`),T(o,r,i,``,function(e){return e})):o!=null&&(re(o)&&(o=ne(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(ae,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(ee(e))for(var u=0;u{n.exports=t()})),r=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),i=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),a=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(e,t,n)=>n?n.toUpperCase():t.toLowerCase()),o=e=>{let t=a(e);return t.charAt(0).toUpperCase()+t.slice(1)},s={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},c=e=>{for(let t in e)if(t.startsWith(`aria-`)||t===`role`||t===`title`)return!0;return!1},l=n(),u=(0,l.createContext)({}),d=()=>(0,l.useContext)(u),f=(0,l.forwardRef)(({color:e,size:t,strokeWidth:n,absoluteStrokeWidth:i,className:a=``,children:o,iconNode:u,...f},p)=>{let{size:m=24,strokeWidth:h=2,absoluteStrokeWidth:g=!1,color:_=`currentColor`,className:v=``}=d()??{},y=i??g?Number(n??h)*24/Number(t??m):n??h;return(0,l.createElement)(`svg`,{ref:p,...s,width:t??m??s.width,height:t??m??s.height,stroke:e??_,strokeWidth:y,className:r(`lucide`,v,a),...!o&&!c(f)&&{"aria-hidden":`true`},...f},[...u.map(([e,t])=>(0,l.createElement)(e,t)),...Array.isArray(o)?o:[o]])}),p=(e,t)=>{let n=(0,l.forwardRef)(({className:n,...a},s)=>(0,l.createElement)(f,{ref:s,iconNode:t,className:r(`lucide-${i(o(e))}`,`lucide-${e}`,n),...a}));return n.displayName=o(e),n},m=p(`activity`,[[`path`,{d:`M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2`,key:`169zse`}]]),h=p(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]),g=p(`bell`,[[`path`,{d:`M10.268 21a2 2 0 0 0 3.464 0`,key:`vwvbt9`}],[`path`,{d:`M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326`,key:`11g9vi`}]]),_=p(`briefcase-business`,[[`path`,{d:`M12 12h.01`,key:`1mp3jc`}],[`path`,{d:`M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2`,key:`1ksdt3`}],[`path`,{d:`M22 13a18.15 18.15 0 0 1-20 0`,key:`12hx5q`}],[`rect`,{width:`20`,height:`14`,x:`2`,y:`6`,rx:`2`,key:`i6l2r4`}]]),v=p(`clipboard-list`,[[`rect`,{width:`8`,height:`4`,x:`8`,y:`2`,rx:`1`,ry:`1`,key:`tgr4d6`}],[`path`,{d:`M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2`,key:`116196`}],[`path`,{d:`M12 11h4`,key:`1jrz19`}],[`path`,{d:`M12 16h4`,key:`n85exb`}],[`path`,{d:`M8 11h.01`,key:`1dfujw`}],[`path`,{d:`M8 16h.01`,key:`18s6g9`}]]),y=p(`external-link`,[[`path`,{d:`M15 3h6v6`,key:`1q9fwt`}],[`path`,{d:`M10 14 21 3`,key:`gplh6r`}],[`path`,{d:`M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6`,key:`a6xqqp`}]]),b=p(`file-clock`,[[`path`,{d:`M16 22h2a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v2.85`,key:`ryk6xj`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`M8 14v2.2l1.6 1`,key:`6m4bie`}],[`circle`,{cx:`8`,cy:`16`,r:`6`,key:`10v15b`}]]),x=p(`folder-kanban`,[[`path`,{d:`M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z`,key:`1fr9dc`}],[`path`,{d:`M8 10v4`,key:`tgpxqk`}],[`path`,{d:`M12 10v2`,key:`hh53o1`}],[`path`,{d:`M16 10v6`,key:`1d6xys`}]]),ee=p(`log-out`,[[`path`,{d:`m16 17 5-5-5-5`,key:`1bji2h`}],[`path`,{d:`M21 12H9`,key:`dn1m92`}],[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}]]),te=p(`mail`,[[`path`,{d:`m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7`,key:`132q7q`}],[`rect`,{x:`2`,y:`4`,width:`20`,height:`16`,rx:`2`,key:`izxlao`}]]),S=p(`plus`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]]),C=p(`refresh-cw`,[[`path`,{d:`M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8`,key:`v9h5vc`}],[`path`,{d:`M21 3v5h-5`,key:`1q7to0`}],[`path`,{d:`M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16`,key:`3uifl3`}],[`path`,{d:`M8 16H3v5`,key:`1cv678`}]]),w=p(`search`,[[`path`,{d:`m21 21-4.34-4.34`,key:`14j7rj`}],[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}]]),ne=p(`send`,[[`path`,{d:`M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z`,key:`1ffxy3`}],[`path`,{d:`m21.854 2.147-10.94 10.939`,key:`12cjpa`}]]),re=p(`settings`,[[`path`,{d:`M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915`,key:`1i5ecw`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]]),ie=p(`shield-check`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),ae=p(`trash-2`,[[`path`,{d:`M10 11v6`,key:`nco0om`}],[`path`,{d:`M14 11v6`,key:`outv1u`}],[`path`,{d:`M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6`,key:`miytrc`}],[`path`,{d:`M3 6h18`,key:`d0wm0j`}],[`path`,{d:`M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2`,key:`e791ji`}]]),oe=p(`user-minus`,[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}],[`line`,{x1:`22`,x2:`16`,y1:`11`,y2:`11`,key:`1shjgl`}]]),se=p(`user-plus`,[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}],[`line`,{x1:`19`,x2:`19`,y1:`8`,y2:`14`,key:`1bvyxn`}],[`line`,{x1:`22`,x2:`16`,y1:`11`,y2:`11`,key:`1shjgl`}]]),T=p(`users`,[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`path`,{d:`M16 3.128a4 4 0 0 1 0 7.744`,key:`16gr8j`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]]),E=p(`x`,[[`path`,{d:`M18 6 6 18`,key:`1bl5f8`}],[`path`,{d:`m6 6 12 12`,key:`d8bk6v`}]]),D=e((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,ee||(ee=!0,re());else{var t=n(l);t!==null&&oe(x,t.startTime-e)}}var ee=!1,te=-1,S=5,C=-1;function w(){return g?!0:!(e.unstable_now()-Ct&&w());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&oe(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?re():ee=!1}}}var re;if(typeof y==`function`)re=function(){y(ne)};else if(typeof MessageChannel<`u`){var ie=new MessageChannel,ae=ie.port2;ie.port1.onmessage=ne,re=function(){ae.postMessage(null)}}else re=function(){_(ne,0)};function oe(t,n){te=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(te),te=-1):h=!0,oe(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,ee||(ee=!0,re()))),r},e.unstable_shouldYield=w,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),O=e(((e,t)=>{t.exports=D()})),ce=e((e=>{var t=n();function r(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=ce()})),ue=e((e=>{var t=O(),r=n(),i=le();function a(e){var t=`https://react.dev/errors/`+e;if(1de||(e.current=ue[de],ue[de]=null,de--)}function j(e,t){de++,ue[de]=e.current,e.current=t}var M=k(null),N=k(null),fe=k(null),pe=k(null);function me(e,t){switch(j(fe,t),j(N,e),j(M,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Vd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Vd(t),e=Hd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}A(M),j(M,e)}function he(){A(M),A(N),A(fe)}function ge(e){e.memoizedState!==null&&j(pe,e);var t=M.current,n=Hd(t,e.type);t!==n&&(j(N,e),j(M,n))}function _e(e){N.current===e&&(A(M),A(N)),pe.current===e&&(A(pe),Qf._currentValue=ce)}var ve,ye;function be(e){if(ve===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);ve=t&&t[1]||``,ye=-1)`:-1i||c[r]!==l[i]){var u=` -`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{xe=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?be(n):``}function Ce(e,t){switch(e.tag){case 26:case 27:case 5:return be(e.type);case 16:return be(`Lazy`);case 13:return e.child!==t&&t!==null?be(`Suspense Fallback`):be(`Suspense`);case 19:return be(`SuspenseList`);case 0:case 15:return Se(e.type,!1);case 11:return Se(e.type.render,!1);case 1:return Se(e.type,!0);case 31:return be(`Activity`);default:return``}}function we(e){try{var t=``,n=null;do t+=Ce(e,n),n=e,e=e.return;while(e);return t}catch(e){return` -Error generating stack: `+e.message+` -`+e.stack}}var Te=Object.prototype.hasOwnProperty,Ee=t.unstable_scheduleCallback,De=t.unstable_cancelCallback,Oe=t.unstable_shouldYield,ke=t.unstable_requestPaint,Ae=t.unstable_now,je=t.unstable_getCurrentPriorityLevel,Me=t.unstable_ImmediatePriority,Ne=t.unstable_UserBlockingPriority,Pe=t.unstable_NormalPriority,Fe=t.unstable_LowPriority,Ie=t.unstable_IdlePriority,Le=t.log,Re=t.unstable_setDisableYieldValue,ze=null,P=null;function Be(e){if(typeof Le==`function`&&Re(e),P&&typeof P.setStrictMode==`function`)try{P.setStrictMode(ze,e)}catch{}}var Ve=Math.clz32?Math.clz32:We,He=Math.log,Ue=Math.LN2;function We(e){return e>>>=0,e===0?32:31-(He(e)/Ue|0)|0}var Ge=256,Ke=262144,qe=4194304;function Je(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Ye(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=Je(n))):i=Je(o):i=Je(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=Je(n))):i=Je(o)):i=Je(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function Xe(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Ze(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Qe(){var e=qe;return qe<<=1,!(qe&62914560)&&(qe=4194304),e}function $e(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function et(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function tt(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),Qt=!1;if(Zt)try{var $t={};Object.defineProperty($t,"passive",{get:function(){Qt=!0}}),window.addEventListener(`test`,$t,$t),window.removeEventListener(`test`,$t,$t)}catch{Qt=!1}var en=null,tn=null,nn=null;function rn(){if(nn)return nn;var e,t=tn,n=t.length,r,i=`value`in en?en.value:en.textContent,a=i.length;for(e=0;e=In),zn=` `,Bn=!1;function Vn(e,t){switch(e){case`keyup`:return Pn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function X(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var Hn=!1;function Un(e,t){switch(e){case`compositionend`:return X(t);case`keypress`:return t.which===32?(Bn=!0,zn):null;case`textInput`:return e=t.data,e===zn&&Bn?null:e;default:return null}}function Wn(e,t){if(Hn)return e===`compositionend`||!Fn&&Vn(e,t)?(e=rn(),nn=tn=en=null,Hn=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=fr(n)}}function mr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?mr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function hr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Lt(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Lt(e.document)}return t}function gr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var _r=Zt&&`documentMode`in document&&11>=document.documentMode,vr=null,yr=null,br=null,xr=!1;function Sr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;xr||vr==null||vr!==Lt(r)||(r=vr,`selectionStart`in r&&gr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),br&&dr(br,r)||(br=r,r=Td(yr,`onSelect`),0>=o,i-=o,mi=1<<32-Ve(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),Ci&&gi(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(i,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(i,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(i,h),Ci&&gi(i,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(i,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return Ci&&gi(i,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,i,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(i,e)}),Ci&&gi(i,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===_&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case h:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===_){if(r.tag===7){n(e,r.sibling),c=i(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===w&&_a(l)===r.type){n(e,r.sibling),c=i(r,o.props),wa(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===_?(c=ti(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=ei(o.type,o.key,o.props,null,e.mode,c),wa(c,o),c.return=e,e=c)}return s(e);case g:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=i(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=ii(o,e.mode,c),c.return=e,e=c}return s(e);case w:return o=_a(o),b(e,r,o,c)}if(T(o))return v(e,r,o,c);if(ae(o)){if(l=ae(o),typeof l!=`function`)throw Error(a(150));return o=l.call(o),y(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,Ca(o),c);if(o.$$typeof===x)return b(e,r,Gi(e,o),c);Ta(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=i(r,o),c.return=e,e=c):(n(e,r),c=ni(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{Sa=0;var i=b(e,t,n,r);return xa=null,i}catch(t){if(t===da||t===pa)throw t;var a=Xr(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var Da=Ea(!0),Oa=Ea(!1),ka=!1;function Aa(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function ja(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Ma(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Na(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,Nl&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=qr(e),Kr(e,null,n),t}return Ur(e,r,t,n),qr(e)}function Pa(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,nt(e,n)}}function Fa(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var Ia=!1;function La(){if(Ia){var e=na;if(e!==null)throw e}}function Ra(e,t,n,r){Ia=!1;var i=e.updateQueue;ka=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,m=f!==s.lane;if(m?(Fl&f)===f:(r&f)===f){f!==0&&f===ta&&(Ia=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var h=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(h=g.payload,typeof h==`function`){d=h.call(_,d,f);break a}d=h;break a;case 3:h.flags=h.flags&-65537|128;case 0:if(h=g.payload,f=typeof h==`function`?h.call(_,d,f):h,f==null)break a;d=p({},d,f);break a;case 2:ka=!0}}f=s.callback,f!==null&&(e.flags|=64,m&&(e.flags|=8192),m=i.callbacks,m===null?i.callbacks=[f]:m.push(f))}else m={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=m,c=d):u=u.next=m,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;m=s,s=m.next,m.next=null,i.lastBaseUpdate=m,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Ul|=o,e.lanes=o,e.memoizedState=d}}function za(e,t){if(typeof e!=`function`)throw Error(a(191,e));e.call(t)}function Ba(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=E.T,s={};E.T=s,Es(e,!1,t,n);try{var c=i(),l=E.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?Ts(e,t,aa(c,r),du(e)):Ts(e,t,r,du(e))}catch(n){Ts(e,t,{then:function(){},status:`rejected`,reason:n},du())}finally{D.p=a,o!==null&&s.types!==null&&(o.types=s.types),E.T=o}}function hs(){}function gs(e,t,n,r){if(e.tag!==5)throw Error(a(476));var i=_s(e).queue;ms(e,i,t,ce,n===null?hs:function(){return vs(e),n(r)})}function _s(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:ce,baseState:ce,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Do,lastRenderedState:ce},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Do,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function vs(e){var t=_s(e);t.next===null&&(t=e.alternate.memoizedState),Ts(e,t.next.queue,{},du())}function ys(){return Wi(Qf)}function bs(){return So().memoizedState}function xs(){return So().memoizedState}function Ss(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=du();e=Ma(n);var r=Na(t,e,n);r!==null&&(pu(r,t,n),Pa(r,t,n)),t={cache:Zi()},e.payload=t;return}t=t.return}}function Cs(e,t,n){var r=du();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},Ds(e)?Os(t,n):(n=Wr(e,t,n,r),n!==null&&(pu(n,e,r),ks(n,t,r)))}function ws(e,t,n){Ts(e,t,n,du())}function Ts(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(Ds(e))Os(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,ur(s,o))return Ur(e,t,i,0),Pl===null&&Hr(),!1}catch{}if(n=Wr(e,t,i,r),n!==null)return pu(n,e,r),ks(n,t,r),!0}return!1}function Es(e,t,n,r){if(r={lane:2,revertLane:ud(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},Ds(e)){if(t)throw Error(a(479))}else t=Wr(e,n,r,2),t!==null&&pu(t,e,2)}function Ds(e){var t=e.alternate;return e===Z||t!==null&&t===Z}function Os(e,t){ao=io=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function ks(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,nt(e,n)}}var As={readContext:Wi,use:To,useCallback:fo,useContext:fo,useEffect:fo,useImperativeHandle:fo,useLayoutEffect:fo,useInsertionEffect:fo,useMemo:fo,useReducer:fo,useRef:fo,useState:fo,useDebugValue:fo,useDeferredValue:fo,useTransition:fo,useSyncExternalStore:fo,useId:fo,useHostTransitionStatus:fo,useFormState:fo,useActionState:fo,useOptimistic:fo,useMemoCache:fo,useCacheRefresh:fo};As.useEffectEvent=fo;var js={readContext:Wi,use:To,useCallback:function(e,t){return xo().memoizedState=[e,t===void 0?null:t],e},useContext:Wi,useEffect:ts,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),$o(4194308,4,ss.bind(null,t,e),n)},useLayoutEffect:function(e,t){return $o(4194308,4,e,t)},useInsertionEffect:function(e,t){$o(4,2,e,t)},useMemo:function(e,t){var n=xo();t=t===void 0?null:t;var r=e();if(oo){Be(!0);try{e()}finally{Be(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=xo();if(n!==void 0){var i=n(t);if(oo){Be(!0);try{n(t)}finally{Be(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=Cs.bind(null,Z,e),[r.memoizedState,e]},useRef:function(e){var t=xo();return e={current:e},t.memoizedState=e},useState:function(e){e=Lo(e);var t=e.queue,n=ws.bind(null,Z,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:ls,useDeferredValue:function(e,t){return fs(xo(),e,t)},useTransition:function(){var e=Lo(!1);return e=ms.bind(null,Z,e.queue,!0,!1),xo().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=Z,i=xo();if(Ci){if(n===void 0)throw Error(a(407));n=n()}else{if(n=t(),Pl===null)throw Error(a(349));Fl&127||Mo(r,t,n)}i.memoizedState=n;var o={value:n,getSnapshot:t};return i.queue=o,ts(Po.bind(null,r,o,e),[e]),r.flags|=2048,Zo(9,{destroy:void 0},No.bind(null,r,o,n,t),null),n},useId:function(){var e=xo(),t=Pl.identifierPrefix;if(Ci){var n=hi,r=mi;n=(r&~(1<<32-Ve(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=so++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(i,{is:r.is}):s.createElement(i)}}o[lt]=t,o[ut]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Pd(o,i,r),i){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&Tc(t)}}return Ac(t),Ec(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Tc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(a(166));if(e=fe.current,Ai(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,i=xi,i!==null)switch(i.tag){case 27:case 5:r=i.memoizedProps}e[lt]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||jd(e.nodeValue,n)),e||Di(t,!0)}else e=Bd(e).createTextNode(r),e[lt]=t,t.stateNode=e}return Ac(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Ai(t),n!==null){if(e===null){if(!r)throw Error(a(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(a(557));e[lt]=t}else ji(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Ac(t),e=!1}else n=Mi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(Qa(t),t):(Qa(t),null);if(t.flags&128)throw Error(a(558))}return Ac(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(i=Ai(t),r!==null&&r.dehydrated!==null){if(e===null){if(!i)throw Error(a(318));if(i=t.memoizedState,i=i===null?null:i.dehydrated,!i)throw Error(a(317));i[lt]=t}else ji(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Ac(t),i=!1}else i=Mi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=i),i=!0;if(!i)return t.flags&256?(Qa(t),t):(Qa(t),null)}return Qa(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,i=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(i=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==i&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),Oc(t,t.updateQueue),Ac(t),null);case 4:return he(),e===null&&xd(t.stateNode.containerInfo),Ac(t),null;case 10:return Ri(t.type),Ac(t),null;case 19:if(A($a),r=t.memoizedState,r===null)return Ac(t),null;if(i=(t.flags&128)!=0,o=r.rendering,o===null)if(i)kc(r,!1);else{if(Hl!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=eo(e),o!==null){for(t.flags|=128,kc(r,!1),e=o.updateQueue,t.updateQueue=e,Oc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)$r(n,e),n=n.sibling;return j($a,$a.current&1|2),Ci&&gi(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&Ae()>$l&&(t.flags|=128,i=!0,kc(r,!1),t.lanes=4194304)}else{if(!i)if(e=eo(o),e!==null){if(t.flags|=128,i=!0,e=e.updateQueue,t.updateQueue=e,Oc(t,e),kc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!Ci)return Ac(t),null}else 2*Ae()-r.renderingStartTime>$l&&n!==536870912&&(t.flags|=128,i=!0,kc(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(Ac(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=Ae(),e.sibling=null,n=$a.current,j($a,i?n&1|2:n&1),Ci&&gi(t,r.treeForkCount),e);case 22:case 23:return Qa(t),Ga(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(Ac(t),t.subtreeFlags&6&&(t.flags|=8192)):Ac(t),n=t.updateQueue,n!==null&&Oc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&A(sa),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),Ri(Xi),Ac(t),null;case 25:return null;case 30:return null}throw Error(a(156,t.tag))}function Mc(e,t){switch(yi(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Ri(Xi),he(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return _e(t),null;case 31:if(t.memoizedState!==null){if(Qa(t),t.alternate===null)throw Error(a(340));ji()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(Qa(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(a(340));ji()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return A($a),null;case 4:return he(),null;case 10:return Ri(t.type),null;case 22:case 23:return Qa(t),Ga(),e!==null&&A(sa),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Ri(Xi),null;case 25:return null;default:return null}}function Nc(e,t){switch(yi(t),t.tag){case 3:Ri(Xi),he();break;case 26:case 27:case 5:_e(t);break;case 4:he();break;case 31:t.memoizedState!==null&&Qa(t);break;case 13:Qa(t);break;case 19:A($a);break;case 10:Ri(t.type);break;case 22:case 23:Qa(t),Ga(),e!==null&&A(sa);break;case 24:Ri(Xi)}}function Pc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Uu(t,t.return,e)}}function Fc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Uu(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Uu(t,t.return,e)}}function Ic(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{Ba(t,n)}catch(t){Uu(e,e.return,t)}}}function Lc(e,t,n){n.props=Rs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Uu(e,t,n)}}function Rc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Uu(e,t,n)}}function zc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){Uu(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Uu(e,t,n)}else n.current=null}function Bc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Uu(e,e.return,t)}}function Vc(e,t,n){try{var r=e.stateNode;Fd(r,e.type,n,t),r[ut]=t}catch(t){Uu(e,e.return,t)}}function Hc(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Zd(e.type)||e.tag===4}function Uc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Hc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Zd(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Wc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=G));else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(Wc(e,t,n),e=e.sibling;e!==null;)Wc(e,t,n),e=e.sibling}function Gc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(Gc(e,t,n),e=e.sibling;e!==null;)Gc(e,t,n),e=e.sibling}function Kc(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Pd(t,r,n),t[lt]=e,t[ut]=n}catch(t){Uu(e,e.return,t)}}var qc=!1,Jc=!1,Yc=!1,Xc=typeof WeakSet==`function`?WeakSet:Set,Zc=null;function Qc(e,t){if(e=e.containerInfo,Rd=sp,e=hr(e),gr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var i=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||i!==0&&f.nodeType!==3||(c=s+i),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===i&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(zd={focusedElem:e,selectionRange:n},sp=!1,Zc=t;Zc!==null;)if(t=Zc,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,Zc=e;else for(;Zc!==null;){switch(t=Zc,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Pd(o,r,n),o[lt]=e,bt(o),r=o;break a;case`link`:var s=Vf(`link`,`href`,i).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=pr(s,h),v=pr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,E.T=null,n=su,su=null;var o=ru,s=au;if(nu=0,iu=ru=null,au=0,Nl&6)throw Error(a(331));var c=Nl;if(Nl|=4,Ol(o.current),bl(o,o.current,s,n),Nl=c,rd(0,!1),P&&typeof P.onPostCommitFiberRoot==`function`)try{P.onPostCommitFiberRoot(ze,o)}catch{}return!0}finally{D.p=i,E.T=r,zu(e,t)}}function Hu(e,t,n){t=oi(n,t),t=Ws(e.stateNode,t,2),e=Na(e,t,2),e!==null&&(et(e,2),nd(e))}function Uu(e,t,n){if(e.tag===3)Hu(e,e,n);else for(;t!==null;){if(t.tag===3){Hu(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(tu===null||!tu.has(r))){e=oi(n,e),n=Gs(2),r=Na(t,n,2),r!==null&&(Ks(n,r,t,e),et(r,2),nd(r));break}}t=t.return}}function Wu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Ml;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Bl=!0,i.add(n),e=Gu.bind(null,e,t,n),t.then(e,e))}function Gu(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,Pl===e&&(Fl&n)===n&&(Hl===4||Hl===3&&(Fl&62914560)===Fl&&300>Ae()-Zl?!(Nl&2)&&bu(e,0):Gl|=n,ql===Fl&&(ql=0)),nd(e)}function Ku(e,t){t===0&&(t=Qe()),e=Gr(e,t),e!==null&&(et(e,t),nd(e))}function qu(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Ku(e,n)}function Ju(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,i=e.memoizedState;i!==null&&(n=i.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(a(314))}r!==null&&r.delete(t),Ku(e,n)}function Yu(e,t){return Ee(e,t)}var Xu=null,Zu=null,Qu=!1,$u=!1,ed=!1,td=0;function nd(e){e!==Zu&&e.next===null&&(Zu===null?Xu=Zu=e:Zu=Zu.next=e),$u=!0,Qu||(Qu=!0,ld())}function rd(e,t){if(!ed&&$u){ed=!0;do for(var n=!1,r=Xu;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-Ve(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,cd(r,a))}else a=Fl,a=Ye(r,r===Pl?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||Xe(r,a)||(n=!0,cd(r,a));r=r.next}while(n);ed=!1}}function id(){ad()}function ad(){$u=Qu=!1;var e=0;td!==0&&Gd()&&(e=td);for(var t=Ae(),n=null,r=Xu;r!==null;){var i=r.next,a=od(r,t);a===0?(r.next=null,n===null?Xu=i:n.next=i,i===null&&(Zu=n)):(n=r,(e!==0||a&3)&&($u=!0)),r=i}nu!==0&&nu!==5||rd(e,!1),td!==0&&(td=0)}function od(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Id(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function xf(e,t,n){var r=bf;if(r&&typeof t==`string`&&t){var i=zt(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),hf.has(i)||(hf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Pd(t,`link`,e),bt(t),r.head.appendChild(t)))}}function Sf(e){_f.D(e),xf(`dns-prefetch`,e,null)}function Cf(e,t){_f.C(e,t),xf(`preconnect`,e,t)}function wf(e,t,n){_f.L(e,t,n);var r=bf;if(r&&e&&t){var i=`link[rel="preload"][as="`+zt(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+zt(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+zt(n.imageSizes)+`"]`)):i+=`[href="`+zt(e)+`"]`;var a=i;switch(t){case`style`:a=Af(e);break;case`script`:a=Pf(e)}mf.has(a)||(e=p({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),mf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(jf(a))||t===`script`&&r.querySelector(Ff(a))||(t=r.createElement(`link`),Pd(t,`link`,e),bt(t),r.head.appendChild(t)))}}function Tf(e,t){_f.m(e,t);var n=bf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+zt(r)+`"][href="`+zt(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Pf(e)}if(!mf.has(a)&&(e=p({rel:`modulepreload`,href:e},t),mf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Ff(a)))return}r=n.createElement(`link`),Pd(r,`link`,e),bt(r),n.head.appendChild(r)}}}function Ef(e,t,n){_f.S(e,t,n);var r=bf;if(r&&e){var i=L(r).hoistableStyles,a=Af(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(jf(a)))s.loading=5;else{e=p({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=mf.get(a))&&Rf(e,n);var c=o=r.createElement(`link`);bt(c),Pd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Lf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Df(e,t){_f.X(e,t);var n=bf;if(n&&e){var r=L(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=p({src:e,async:!0},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),bt(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Of(e,t){_f.M(e,t);var n=bf;if(n&&e){var r=L(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=p({src:e,async:!0,type:`module`},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),bt(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function kf(e,t,n,r){var i=(i=fe.current)?gf(i):null;if(!i)throw Error(a(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Af(n.href),n=L(i).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Af(n.href);var o=L(i).hoistableStyles,s=o.get(e);if(s||(i=i.ownerDocument||i,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=i.querySelector(jf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),mf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},mf.set(e,n),o||Nf(i,e,n,s.state))),t&&r===null)throw Error(a(528,``));return s}if(t&&r!==null)throw Error(a(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Pf(n),n=L(i).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(a(444,e))}}function Af(e){return`href="`+zt(e)+`"`}function jf(e){return`link[rel="stylesheet"][`+e+`]`}function Mf(e){return p({},e,{"data-precedence":e.precedence,precedence:null})}function Nf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Pd(t,`link`,n),bt(t),e.head.appendChild(t))}function Pf(e){return`[src="`+zt(e)+`"]`}function Ff(e){return`script[async]`+e}function If(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+zt(n.href)+`"]`);if(r)return t.instance=r,bt(r),r;var i=p({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),bt(r),Pd(r,`style`,i),Lf(r,n.precedence,e),t.instance=r;case`stylesheet`:i=Af(n.href);var o=e.querySelector(jf(i));if(o)return t.state.loading|=4,t.instance=o,bt(o),o;r=Mf(n),(i=mf.get(i))&&Rf(r,i),o=(e.ownerDocument||e).createElement(`link`),bt(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Pd(o,`link`,r),t.state.loading|=4,Lf(o,n.precedence,e),t.instance=o;case`script`:return o=Pf(n.src),(i=e.querySelector(Ff(o)))?(t.instance=i,bt(i),i):(r=n,(i=mf.get(o))&&(r=p({},n),zf(r,i)),e=e.ownerDocument||e,i=e.createElement(`script`),bt(i),Pd(i,`link`,r),e.head.appendChild(i),t.instance=i);case`void`:return null;default:throw Error(a(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Lf(r,n.precedence,e));return t.instance}function Lf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Uf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Wf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Gf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Af(r.href),a=t.querySelector(jf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=Jf.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,bt(a);return}a=t.ownerDocument||t,r=Mf(r),(i=mf.get(i))&&Rf(r,i),a=a.createElement(`link`),bt(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Pd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Jf.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Kf=0;function qf(e,t){return e.stylesheets&&e.count===0&&Xf(e,e.stylesheets),0Kf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function Jf(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Xf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Yf=null;function Xf(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Yf=new Map,t.forEach(Zf,e),Yf=null,Jf.call(e))}function Zf(e,t){if(!(t.state.loading&4)){var n=Yf.get(e);if(n)var r=n.get(null);else{n=new Map,Yf.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=ue()})),k=e((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),A=e(((e,t)=>{t.exports=k()})),j=de(),M=A();function N({children:e,hidden:t}){return t?null:(0,M.jsx)(`div`,{className:`px-4 py-7 text-center text-sm text-muted-foreground`,children:e})}function fe(e){var t,n,r=``;if(typeof e==`string`||typeof e==`number`)r+=e;else if(typeof e==`object`)if(Array.isArray(e)){var i=e.length;for(t=0;t{let n=Array(e.length+t.length);for(let t=0;t({classGroupId:e,validator:t}),ge=(e=new Map,t=null,n)=>({nextPart:e,validators:t,classGroupId:n}),_e=`-`,ve=[],ye=`arbitrary..`,be=e=>{let t=Ce(e),{conflictingClassGroups:n,conflictingClassGroupModifiers:r}=e;return{getClassGroupId:e=>{if(e.startsWith(`[`)&&e.endsWith(`]`))return Se(e);let n=e.split(_e);return xe(n,+(n[0]===``&&n.length>1),t)},getConflictingClassGroupIds:(e,t)=>{if(t){let t=r[e],i=n[e];return t?i?me(i,t):t:i||ve}return n[e]||ve}}},xe=(e,t,n)=>{if(e.length-t===0)return n.classGroupId;let r=e[t],i=n.nextPart.get(r);if(i){let n=xe(e,t+1,i);if(n)return n}let a=n.validators;if(a===null)return;let o=t===0?e.join(_e):e.slice(t).join(_e),s=a.length;for(let e=0;ee.slice(1,-1).indexOf(`:`)===-1?void 0:(()=>{let t=e.slice(1,-1),n=t.indexOf(`:`),r=t.slice(0,n);return r?ye+r:void 0})(),Ce=e=>{let{theme:t,classGroups:n}=e;return we(n,t)},we=(e,t)=>{let n=ge();for(let r in e){let i=e[r];Te(i,n,r,t)}return n},Te=(e,t,n,r)=>{let i=e.length;for(let a=0;a{if(typeof e==`string`){De(e,t,n);return}if(typeof e==`function`){Oe(e,t,n,r);return}ke(e,t,n,r)},De=(e,t,n)=>{let r=e===``?t:Ae(t,e);r.classGroupId=n},Oe=(e,t,n,r)=>{if(je(e)){Te(e(r),t,n,r);return}t.validators===null&&(t.validators=[]),t.validators.push(he(n,e))},ke=(e,t,n,r)=>{let i=Object.entries(e),a=i.length;for(let e=0;e{let n=e,r=t.split(_e),i=r.length;for(let e=0;e`isThemeGetter`in e&&e.isThemeGetter===!0,Me=e=>{if(e<1)return{get:()=>void 0,set:()=>{}};let t=0,n=Object.create(null),r=Object.create(null),i=(i,a)=>{n[i]=a,t++,t>e&&(t=0,r=n,n=Object.create(null))};return{get(e){let t=n[e];if(t!==void 0)return t;if((t=r[e])!==void 0)return i(e,t),t},set(e,t){e in n?n[e]=t:i(e,t)}}},Ne=`!`,Pe=`:`,Fe=[],Ie=(e,t,n,r,i)=>({modifiers:e,hasImportantModifier:t,baseClassName:n,maybePostfixModifierPosition:r,isExternal:i}),Le=e=>{let{prefix:t,experimentalParseClassName:n}=e,r=e=>{let t=[],n=0,r=0,i=0,a,o=e.length;for(let s=0;si?a-i:void 0;return Ie(t,l,c,u)};if(t){let e=t+Pe,n=r;r=t=>t.startsWith(e)?n(t.slice(e.length)):Ie(Fe,!1,t,void 0,!0)}if(n){let e=r;r=t=>n({className:t,parseClassName:e})}return r},Re=e=>{let t=new Map;return e.orderSensitiveModifiers.forEach((e,n)=>{t.set(e,1e6+n)}),e=>{let n=[],r=[];for(let i=0;i0&&(r.sort(),n.push(...r),r=[]),n.push(a)):r.push(a)}return r.length>0&&(r.sort(),n.push(...r)),n}},ze=e=>({cache:Me(e.cacheSize),parseClassName:Le(e),sortModifiers:Re(e),postfixLookupClassGroupIds:P(e),...be(e)}),P=e=>{let t=Object.create(null),n=e.postfixLookupClassGroups;if(n)for(let e=0;e{let{parseClassName:n,getClassGroupId:r,getConflictingClassGroupIds:i,sortModifiers:a,postfixLookupClassGroupIds:o}=t,s=[],c=e.trim().split(Be),l=``;for(let e=c.length-1;e>=0;--e){let t=c[e],{isExternal:u,modifiers:d,hasImportantModifier:f,baseClassName:p,maybePostfixModifierPosition:m}=n(t);if(u){l=t+(l.length>0?` `+l:l);continue}let h=!!m,g;if(h){g=r(p.substring(0,m));let e=g&&o[g]?r(p):void 0;e&&e!==g&&(g=e,h=!1)}else g=r(p);if(!g){if(!h){l=t+(l.length>0?` `+l:l);continue}if(g=r(p),!g){l=t+(l.length>0?` `+l:l);continue}h=!1}let _=d.length===0?``:d.length===1?d[0]:a(d).join(`:`),v=f?_+Ne:_,y=v+g;if(s.indexOf(y)>-1)continue;s.push(y);let b=i(g,h);for(let e=0;e0?` `+l:l)}return l},He=(...e)=>{let t=0,n,r,i=``;for(;t{if(typeof e==`string`)return e;let t,n=``;for(let r=0;r{let n,r,i,a,o=o=>(n=ze(t.reduce((e,t)=>t(e),e())),r=n.cache.get,i=n.cache.set,a=s,s(o)),s=e=>{let t=r(e);if(t)return t;let a=Ve(e,n);return i(e,a),a};return a=o,(...e)=>a(He(...e))},Ge=[],Ke=e=>{let t=t=>t[e]||Ge;return t.isThemeGetter=!0,t},qe=/^\[(?:(\w[\w-]*):)?(.+)\]$/i,Je=/^\((?:(\w[\w-]*):)?(.+)\)$/i,Ye=/^\d+(?:\.\d+)?\/\d+(?:\.\d+)?$/,Xe=/^(\d+(\.\d+)?)?(xs|sm|md|lg|xl)$/,Ze=/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh|cq(w|h|i|b|min|max))|\b(calc|min|max|clamp)\(.+\)|^0$/,Qe=/^(rgba?|hsla?|hwb|(ok)?(lab|lch)|color-mix)\(.+\)$/,$e=/^(inset_)?-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/,et=/^(url|image|image-set|cross-fade|element|(repeating-)?(linear|radial|conic)-gradient)\(.+\)$/,tt=e=>Ye.test(e),F=e=>!!e&&!Number.isNaN(Number(e)),nt=e=>!!e&&Number.isInteger(Number(e)),rt=e=>e.endsWith(`%`)&&F(e.slice(0,-1)),it=e=>Xe.test(e),at=()=>!0,ot=e=>Ze.test(e)&&!Qe.test(e),st=()=>!1,ct=e=>$e.test(e),lt=e=>et.test(e),ut=e=>!I(e)&&!L(e),dt=e=>e.startsWith(`@container`)&&(e[10]===`/`&&e[11]!==void 0||e[11]===`s`&&e[16]!==void 0&&e.startsWith(`-size/`,10)||e[11]===`n`&&e[18]!==void 0&&e.startsWith(`-normal/`,10)),ft=e=>Dt(e,jt,st),I=e=>qe.test(e),pt=e=>Dt(e,Mt,ot),mt=e=>Dt(e,Nt,F),ht=e=>Dt(e,Ft,at),gt=e=>Dt(e,Pt,st),_t=e=>Dt(e,kt,st),vt=e=>Dt(e,At,lt),yt=e=>Dt(e,It,ct),L=e=>Je.test(e),bt=e=>Ot(e,Mt),xt=e=>Ot(e,Pt),St=e=>Ot(e,kt),Ct=e=>Ot(e,jt),wt=e=>Ot(e,At),Tt=e=>Ot(e,It,!0),Et=e=>Ot(e,Ft,!0),Dt=(e,t,n)=>{let r=qe.exec(e);return r?r[1]?t(r[1]):n(r[2]):!1},Ot=(e,t,n=!1)=>{let r=Je.exec(e);return r?r[1]?t(r[1]):n:!1},kt=e=>e===`position`||e===`percentage`,At=e=>e===`image`||e===`url`,jt=e=>e===`length`||e===`size`||e===`bg-size`,Mt=e=>e===`length`,Nt=e=>e===`number`,Pt=e=>e===`family-name`,Ft=e=>e===`number`||e===`weight`,It=e=>e===`shadow`,Lt=We(()=>{let e=Ke(`color`),t=Ke(`font`),n=Ke(`text`),r=Ke(`font-weight`),i=Ke(`tracking`),a=Ke(`leading`),o=Ke(`breakpoint`),s=Ke(`container`),c=Ke(`spacing`),l=Ke(`radius`),u=Ke(`shadow`),d=Ke(`inset-shadow`),f=Ke(`text-shadow`),p=Ke(`drop-shadow`),m=Ke(`blur`),h=Ke(`perspective`),g=Ke(`aspect`),_=Ke(`ease`),v=Ke(`animate`),y=()=>[`auto`,`avoid`,`all`,`avoid-page`,`page`,`left`,`right`,`column`],b=()=>[`center`,`top`,`bottom`,`left`,`right`,`top-left`,`left-top`,`top-right`,`right-top`,`bottom-right`,`right-bottom`,`bottom-left`,`left-bottom`],x=()=>[...b(),L,I],ee=()=>[`auto`,`hidden`,`clip`,`visible`,`scroll`],te=()=>[`auto`,`contain`,`none`],S=()=>[L,I,c],C=()=>[tt,`full`,`auto`,...S()],w=()=>[nt,`none`,`subgrid`,L,I],ne=()=>[`auto`,{span:[`full`,nt,L,I]},nt,L,I],re=()=>[nt,`auto`,L,I],ie=()=>[`auto`,`min`,`max`,`fr`,L,I],ae=()=>[`start`,`end`,`center`,`between`,`around`,`evenly`,`stretch`,`baseline`,`center-safe`,`end-safe`],oe=()=>[`start`,`end`,`center`,`stretch`,`center-safe`,`end-safe`],se=()=>[`auto`,...S()],T=()=>[tt,`auto`,`full`,`dvw`,`dvh`,`lvw`,`lvh`,`svw`,`svh`,`min`,`max`,`fit`,...S()],E=()=>[tt,`screen`,`full`,`dvw`,`lvw`,`svw`,`min`,`max`,`fit`,...S()],D=()=>[tt,`screen`,`full`,`lh`,`dvh`,`lvh`,`svh`,`min`,`max`,`fit`,...S()],O=()=>[e,L,I],ce=()=>[...b(),St,_t,{position:[L,I]}],le=()=>[`no-repeat`,{repeat:[``,`x`,`y`,`space`,`round`]}],ue=()=>[`auto`,`cover`,`contain`,Ct,ft,{size:[L,I]}],de=()=>[rt,bt,pt],k=()=>[``,`none`,`full`,l,L,I],A=()=>[``,F,bt,pt],j=()=>[`solid`,`dashed`,`dotted`,`double`],M=()=>[`normal`,`multiply`,`screen`,`overlay`,`darken`,`lighten`,`color-dodge`,`color-burn`,`hard-light`,`soft-light`,`difference`,`exclusion`,`hue`,`saturation`,`color`,`luminosity`],N=()=>[F,rt,St,_t],fe=()=>[``,`none`,m,L,I],pe=()=>[`none`,F,L,I],me=()=>[`none`,F,L,I],he=()=>[F,L,I],ge=()=>[tt,`full`,...S()];return{cacheSize:500,theme:{animate:[`spin`,`ping`,`pulse`,`bounce`],aspect:[`video`],blur:[it],breakpoint:[it],color:[at],container:[it],"drop-shadow":[it],ease:[`in`,`out`,`in-out`],font:[ut],"font-weight":[`thin`,`extralight`,`light`,`normal`,`medium`,`semibold`,`bold`,`extrabold`,`black`],"inset-shadow":[it],leading:[`none`,`tight`,`snug`,`normal`,`relaxed`,`loose`],perspective:[`dramatic`,`near`,`normal`,`midrange`,`distant`,`none`],radius:[it],shadow:[it],spacing:[`px`,F],text:[it],"text-shadow":[it],tracking:[`tighter`,`tight`,`normal`,`wide`,`wider`,`widest`]},classGroups:{aspect:[{aspect:[`auto`,`square`,tt,I,L,g]}],container:[`container`],"container-type":[{"@container":[``,`normal`,`size`,L,I]}],"container-named":[dt],columns:[{columns:[F,I,L,s]}],"break-after":[{"break-after":y()}],"break-before":[{"break-before":y()}],"break-inside":[{"break-inside":[`auto`,`avoid`,`avoid-page`,`avoid-column`]}],"box-decoration":[{"box-decoration":[`slice`,`clone`]}],box:[{box:[`border`,`content`]}],display:[`block`,`inline-block`,`inline`,`flex`,`inline-flex`,`table`,`inline-table`,`table-caption`,`table-cell`,`table-column`,`table-column-group`,`table-footer-group`,`table-header-group`,`table-row-group`,`table-row`,`flow-root`,`grid`,`inline-grid`,`contents`,`list-item`,`hidden`],sr:[`sr-only`,`not-sr-only`],float:[{float:[`right`,`left`,`none`,`start`,`end`]}],clear:[{clear:[`left`,`right`,`both`,`none`,`start`,`end`]}],isolation:[`isolate`,`isolation-auto`],"object-fit":[{object:[`contain`,`cover`,`fill`,`none`,`scale-down`]}],"object-position":[{object:x()}],overflow:[{overflow:ee()}],"overflow-x":[{"overflow-x":ee()}],"overflow-y":[{"overflow-y":ee()}],overscroll:[{overscroll:te()}],"overscroll-x":[{"overscroll-x":te()}],"overscroll-y":[{"overscroll-y":te()}],position:[`static`,`fixed`,`absolute`,`relative`,`sticky`],inset:[{inset:C()}],"inset-x":[{"inset-x":C()}],"inset-y":[{"inset-y":C()}],start:[{"inset-s":C(),start:C()}],end:[{"inset-e":C(),end:C()}],"inset-bs":[{"inset-bs":C()}],"inset-be":[{"inset-be":C()}],top:[{top:C()}],right:[{right:C()}],bottom:[{bottom:C()}],left:[{left:C()}],visibility:[`visible`,`invisible`,`collapse`],z:[{z:[nt,`auto`,L,I]}],basis:[{basis:[tt,`full`,`auto`,s,...S()]}],"flex-direction":[{flex:[`row`,`row-reverse`,`col`,`col-reverse`]}],"flex-wrap":[{flex:[`nowrap`,`wrap`,`wrap-reverse`]}],flex:[{flex:[F,tt,`auto`,`initial`,`none`,I]}],grow:[{grow:[``,F,L,I]}],shrink:[{shrink:[``,F,L,I]}],order:[{order:[nt,`first`,`last`,`none`,L,I]}],"grid-cols":[{"grid-cols":w()}],"col-start-end":[{col:ne()}],"col-start":[{"col-start":re()}],"col-end":[{"col-end":re()}],"grid-rows":[{"grid-rows":w()}],"row-start-end":[{row:ne()}],"row-start":[{"row-start":re()}],"row-end":[{"row-end":re()}],"grid-flow":[{"grid-flow":[`row`,`col`,`dense`,`row-dense`,`col-dense`]}],"auto-cols":[{"auto-cols":ie()}],"auto-rows":[{"auto-rows":ie()}],gap:[{gap:S()}],"gap-x":[{"gap-x":S()}],"gap-y":[{"gap-y":S()}],"justify-content":[{justify:[...ae(),`normal`]}],"justify-items":[{"justify-items":[...oe(),`normal`]}],"justify-self":[{"justify-self":[`auto`,...oe()]}],"align-content":[{content:[`normal`,...ae()]}],"align-items":[{items:[...oe(),{baseline:[``,`last`]}]}],"align-self":[{self:[`auto`,...oe(),{baseline:[``,`last`]}]}],"place-content":[{"place-content":ae()}],"place-items":[{"place-items":[...oe(),`baseline`]}],"place-self":[{"place-self":[`auto`,...oe()]}],p:[{p:S()}],px:[{px:S()}],py:[{py:S()}],ps:[{ps:S()}],pe:[{pe:S()}],pbs:[{pbs:S()}],pbe:[{pbe:S()}],pt:[{pt:S()}],pr:[{pr:S()}],pb:[{pb:S()}],pl:[{pl:S()}],m:[{m:se()}],mx:[{mx:se()}],my:[{my:se()}],ms:[{ms:se()}],me:[{me:se()}],mbs:[{mbs:se()}],mbe:[{mbe:se()}],mt:[{mt:se()}],mr:[{mr:se()}],mb:[{mb:se()}],ml:[{ml:se()}],"space-x":[{"space-x":S()}],"space-x-reverse":[`space-x-reverse`],"space-y":[{"space-y":S()}],"space-y-reverse":[`space-y-reverse`],size:[{size:T()}],"inline-size":[{inline:[`auto`,...E()]}],"min-inline-size":[{"min-inline":[`auto`,...E()]}],"max-inline-size":[{"max-inline":[`none`,...E()]}],"block-size":[{block:[`auto`,...D()]}],"min-block-size":[{"min-block":[`auto`,...D()]}],"max-block-size":[{"max-block":[`none`,...D()]}],w:[{w:[s,`screen`,...T()]}],"min-w":[{"min-w":[s,`screen`,`none`,...T()]}],"max-w":[{"max-w":[s,`screen`,`none`,`prose`,{screen:[o]},...T()]}],h:[{h:[`screen`,`lh`,...T()]}],"min-h":[{"min-h":[`screen`,`lh`,`none`,...T()]}],"max-h":[{"max-h":[`screen`,`lh`,...T()]}],"font-size":[{text:[`base`,n,bt,pt]}],"font-smoothing":[`antialiased`,`subpixel-antialiased`],"font-style":[`italic`,`not-italic`],"font-weight":[{font:[r,Et,ht]}],"font-stretch":[{"font-stretch":[`ultra-condensed`,`extra-condensed`,`condensed`,`semi-condensed`,`normal`,`semi-expanded`,`expanded`,`extra-expanded`,`ultra-expanded`,rt,I]}],"font-family":[{font:[xt,gt,t]}],"font-features":[{"font-features":[I]}],"fvn-normal":[`normal-nums`],"fvn-ordinal":[`ordinal`],"fvn-slashed-zero":[`slashed-zero`],"fvn-figure":[`lining-nums`,`oldstyle-nums`],"fvn-spacing":[`proportional-nums`,`tabular-nums`],"fvn-fraction":[`diagonal-fractions`,`stacked-fractions`],tracking:[{tracking:[i,L,I]}],"line-clamp":[{"line-clamp":[F,`none`,L,mt]}],leading:[{leading:[a,...S()]}],"list-image":[{"list-image":[`none`,L,I]}],"list-style-position":[{list:[`inside`,`outside`]}],"list-style-type":[{list:[`disc`,`decimal`,`none`,L,I]}],"text-alignment":[{text:[`left`,`center`,`right`,`justify`,`start`,`end`]}],"placeholder-color":[{placeholder:O()}],"text-color":[{text:O()}],"text-decoration":[`underline`,`overline`,`line-through`,`no-underline`],"text-decoration-style":[{decoration:[...j(),`wavy`]}],"text-decoration-thickness":[{decoration:[F,`from-font`,`auto`,L,pt]}],"text-decoration-color":[{decoration:O()}],"underline-offset":[{"underline-offset":[F,`auto`,L,I]}],"text-transform":[`uppercase`,`lowercase`,`capitalize`,`normal-case`],"text-overflow":[`truncate`,`text-ellipsis`,`text-clip`],"text-wrap":[{text:[`wrap`,`nowrap`,`balance`,`pretty`]}],indent:[{indent:S()}],"tab-size":[{tab:[nt,L,I]}],"vertical-align":[{align:[`baseline`,`top`,`middle`,`bottom`,`text-top`,`text-bottom`,`sub`,`super`,L,I]}],whitespace:[{whitespace:[`normal`,`nowrap`,`pre`,`pre-line`,`pre-wrap`,`break-spaces`]}],break:[{break:[`normal`,`words`,`all`,`keep`]}],wrap:[{wrap:[`break-word`,`anywhere`,`normal`]}],hyphens:[{hyphens:[`none`,`manual`,`auto`]}],content:[{content:[`none`,L,I]}],"bg-attachment":[{bg:[`fixed`,`local`,`scroll`]}],"bg-clip":[{"bg-clip":[`border`,`padding`,`content`,`text`]}],"bg-origin":[{"bg-origin":[`border`,`padding`,`content`]}],"bg-position":[{bg:ce()}],"bg-repeat":[{bg:le()}],"bg-size":[{bg:ue()}],"bg-image":[{bg:[`none`,{linear:[{to:[`t`,`tr`,`r`,`br`,`b`,`bl`,`l`,`tl`]},nt,L,I],radial:[``,L,I],conic:[nt,L,I]},wt,vt]}],"bg-color":[{bg:O()}],"gradient-from-pos":[{from:de()}],"gradient-via-pos":[{via:de()}],"gradient-to-pos":[{to:de()}],"gradient-from":[{from:O()}],"gradient-via":[{via:O()}],"gradient-to":[{to:O()}],rounded:[{rounded:k()}],"rounded-s":[{"rounded-s":k()}],"rounded-e":[{"rounded-e":k()}],"rounded-t":[{"rounded-t":k()}],"rounded-r":[{"rounded-r":k()}],"rounded-b":[{"rounded-b":k()}],"rounded-l":[{"rounded-l":k()}],"rounded-ss":[{"rounded-ss":k()}],"rounded-se":[{"rounded-se":k()}],"rounded-ee":[{"rounded-ee":k()}],"rounded-es":[{"rounded-es":k()}],"rounded-tl":[{"rounded-tl":k()}],"rounded-tr":[{"rounded-tr":k()}],"rounded-br":[{"rounded-br":k()}],"rounded-bl":[{"rounded-bl":k()}],"border-w":[{border:A()}],"border-w-x":[{"border-x":A()}],"border-w-y":[{"border-y":A()}],"border-w-s":[{"border-s":A()}],"border-w-e":[{"border-e":A()}],"border-w-bs":[{"border-bs":A()}],"border-w-be":[{"border-be":A()}],"border-w-t":[{"border-t":A()}],"border-w-r":[{"border-r":A()}],"border-w-b":[{"border-b":A()}],"border-w-l":[{"border-l":A()}],"divide-x":[{"divide-x":A()}],"divide-x-reverse":[`divide-x-reverse`],"divide-y":[{"divide-y":A()}],"divide-y-reverse":[`divide-y-reverse`],"border-style":[{border:[...j(),`hidden`,`none`]}],"divide-style":[{divide:[...j(),`hidden`,`none`]}],"border-color":[{border:O()}],"border-color-x":[{"border-x":O()}],"border-color-y":[{"border-y":O()}],"border-color-s":[{"border-s":O()}],"border-color-e":[{"border-e":O()}],"border-color-bs":[{"border-bs":O()}],"border-color-be":[{"border-be":O()}],"border-color-t":[{"border-t":O()}],"border-color-r":[{"border-r":O()}],"border-color-b":[{"border-b":O()}],"border-color-l":[{"border-l":O()}],"divide-color":[{divide:O()}],"outline-style":[{outline:[...j(),`none`,`hidden`]}],"outline-offset":[{"outline-offset":[F,L,I]}],"outline-w":[{outline:[``,F,bt,pt]}],"outline-color":[{outline:O()}],shadow:[{shadow:[``,`none`,u,Tt,yt]}],"shadow-color":[{shadow:O()}],"inset-shadow":[{"inset-shadow":[`none`,d,Tt,yt]}],"inset-shadow-color":[{"inset-shadow":O()}],"ring-w":[{ring:A()}],"ring-w-inset":[`ring-inset`],"ring-color":[{ring:O()}],"ring-offset-w":[{"ring-offset":[F,pt]}],"ring-offset-color":[{"ring-offset":O()}],"inset-ring-w":[{"inset-ring":A()}],"inset-ring-color":[{"inset-ring":O()}],"text-shadow":[{"text-shadow":[`none`,f,Tt,yt]}],"text-shadow-color":[{"text-shadow":O()}],opacity:[{opacity:[F,L,I]}],"mix-blend":[{"mix-blend":[...M(),`plus-darker`,`plus-lighter`]}],"bg-blend":[{"bg-blend":M()}],"mask-clip":[{"mask-clip":[`border`,`padding`,`content`,`fill`,`stroke`,`view`]},`mask-no-clip`],"mask-composite":[{mask:[`add`,`subtract`,`intersect`,`exclude`]}],"mask-image-linear-pos":[{"mask-linear":[F]}],"mask-image-linear-from-pos":[{"mask-linear-from":N()}],"mask-image-linear-to-pos":[{"mask-linear-to":N()}],"mask-image-linear-from-color":[{"mask-linear-from":O()}],"mask-image-linear-to-color":[{"mask-linear-to":O()}],"mask-image-t-from-pos":[{"mask-t-from":N()}],"mask-image-t-to-pos":[{"mask-t-to":N()}],"mask-image-t-from-color":[{"mask-t-from":O()}],"mask-image-t-to-color":[{"mask-t-to":O()}],"mask-image-r-from-pos":[{"mask-r-from":N()}],"mask-image-r-to-pos":[{"mask-r-to":N()}],"mask-image-r-from-color":[{"mask-r-from":O()}],"mask-image-r-to-color":[{"mask-r-to":O()}],"mask-image-b-from-pos":[{"mask-b-from":N()}],"mask-image-b-to-pos":[{"mask-b-to":N()}],"mask-image-b-from-color":[{"mask-b-from":O()}],"mask-image-b-to-color":[{"mask-b-to":O()}],"mask-image-l-from-pos":[{"mask-l-from":N()}],"mask-image-l-to-pos":[{"mask-l-to":N()}],"mask-image-l-from-color":[{"mask-l-from":O()}],"mask-image-l-to-color":[{"mask-l-to":O()}],"mask-image-x-from-pos":[{"mask-x-from":N()}],"mask-image-x-to-pos":[{"mask-x-to":N()}],"mask-image-x-from-color":[{"mask-x-from":O()}],"mask-image-x-to-color":[{"mask-x-to":O()}],"mask-image-y-from-pos":[{"mask-y-from":N()}],"mask-image-y-to-pos":[{"mask-y-to":N()}],"mask-image-y-from-color":[{"mask-y-from":O()}],"mask-image-y-to-color":[{"mask-y-to":O()}],"mask-image-radial":[{"mask-radial":[L,I]}],"mask-image-radial-from-pos":[{"mask-radial-from":N()}],"mask-image-radial-to-pos":[{"mask-radial-to":N()}],"mask-image-radial-from-color":[{"mask-radial-from":O()}],"mask-image-radial-to-color":[{"mask-radial-to":O()}],"mask-image-radial-shape":[{"mask-radial":[`circle`,`ellipse`]}],"mask-image-radial-size":[{"mask-radial":[{closest:[`side`,`corner`],farthest:[`side`,`corner`]}]}],"mask-image-radial-pos":[{"mask-radial-at":b()}],"mask-image-conic-pos":[{"mask-conic":[F]}],"mask-image-conic-from-pos":[{"mask-conic-from":N()}],"mask-image-conic-to-pos":[{"mask-conic-to":N()}],"mask-image-conic-from-color":[{"mask-conic-from":O()}],"mask-image-conic-to-color":[{"mask-conic-to":O()}],"mask-mode":[{mask:[`alpha`,`luminance`,`match`]}],"mask-origin":[{"mask-origin":[`border`,`padding`,`content`,`fill`,`stroke`,`view`]}],"mask-position":[{mask:ce()}],"mask-repeat":[{mask:le()}],"mask-size":[{mask:ue()}],"mask-type":[{"mask-type":[`alpha`,`luminance`]}],"mask-image":[{mask:[`none`,L,I]}],filter:[{filter:[``,`none`,L,I]}],blur:[{blur:fe()}],brightness:[{brightness:[F,L,I]}],contrast:[{contrast:[F,L,I]}],"drop-shadow":[{"drop-shadow":[``,`none`,p,Tt,yt]}],"drop-shadow-color":[{"drop-shadow":O()}],grayscale:[{grayscale:[``,F,L,I]}],"hue-rotate":[{"hue-rotate":[F,L,I]}],invert:[{invert:[``,F,L,I]}],saturate:[{saturate:[F,L,I]}],sepia:[{sepia:[``,F,L,I]}],"backdrop-filter":[{"backdrop-filter":[``,`none`,L,I]}],"backdrop-blur":[{"backdrop-blur":fe()}],"backdrop-brightness":[{"backdrop-brightness":[F,L,I]}],"backdrop-contrast":[{"backdrop-contrast":[F,L,I]}],"backdrop-grayscale":[{"backdrop-grayscale":[``,F,L,I]}],"backdrop-hue-rotate":[{"backdrop-hue-rotate":[F,L,I]}],"backdrop-invert":[{"backdrop-invert":[``,F,L,I]}],"backdrop-opacity":[{"backdrop-opacity":[F,L,I]}],"backdrop-saturate":[{"backdrop-saturate":[F,L,I]}],"backdrop-sepia":[{"backdrop-sepia":[``,F,L,I]}],"border-collapse":[{border:[`collapse`,`separate`]}],"border-spacing":[{"border-spacing":S()}],"border-spacing-x":[{"border-spacing-x":S()}],"border-spacing-y":[{"border-spacing-y":S()}],"table-layout":[{table:[`auto`,`fixed`]}],caption:[{caption:[`top`,`bottom`]}],transition:[{transition:[``,`all`,`colors`,`opacity`,`shadow`,`transform`,`none`,L,I]}],"transition-behavior":[{transition:[`normal`,`discrete`]}],duration:[{duration:[F,`initial`,L,I]}],ease:[{ease:[`linear`,`initial`,_,L,I]}],delay:[{delay:[F,L,I]}],animate:[{animate:[`none`,v,L,I]}],backface:[{backface:[`hidden`,`visible`]}],perspective:[{perspective:[h,L,I]}],"perspective-origin":[{"perspective-origin":x()}],rotate:[{rotate:pe()}],"rotate-x":[{"rotate-x":pe()}],"rotate-y":[{"rotate-y":pe()}],"rotate-z":[{"rotate-z":pe()}],scale:[{scale:me()}],"scale-x":[{"scale-x":me()}],"scale-y":[{"scale-y":me()}],"scale-z":[{"scale-z":me()}],"scale-3d":[`scale-3d`],skew:[{skew:he()}],"skew-x":[{"skew-x":he()}],"skew-y":[{"skew-y":he()}],transform:[{transform:[L,I,``,`none`,`gpu`,`cpu`]}],"transform-origin":[{origin:x()}],"transform-style":[{transform:[`3d`,`flat`]}],translate:[{translate:ge()}],"translate-x":[{"translate-x":ge()}],"translate-y":[{"translate-y":ge()}],"translate-z":[{"translate-z":ge()}],"translate-none":[`translate-none`],zoom:[{zoom:[nt,L,I]}],accent:[{accent:O()}],appearance:[{appearance:[`none`,`auto`]}],"caret-color":[{caret:O()}],"color-scheme":[{scheme:[`normal`,`dark`,`light`,`light-dark`,`only-dark`,`only-light`]}],cursor:[{cursor:[`auto`,`default`,`pointer`,`wait`,`text`,`move`,`help`,`not-allowed`,`none`,`context-menu`,`progress`,`cell`,`crosshair`,`vertical-text`,`alias`,`copy`,`no-drop`,`grab`,`grabbing`,`all-scroll`,`col-resize`,`row-resize`,`n-resize`,`e-resize`,`s-resize`,`w-resize`,`ne-resize`,`nw-resize`,`se-resize`,`sw-resize`,`ew-resize`,`ns-resize`,`nesw-resize`,`nwse-resize`,`zoom-in`,`zoom-out`,L,I]}],"field-sizing":[{"field-sizing":[`fixed`,`content`]}],"pointer-events":[{"pointer-events":[`auto`,`none`]}],resize:[{resize:[`none`,``,`y`,`x`]}],"scroll-behavior":[{scroll:[`auto`,`smooth`]}],"scrollbar-thumb-color":[{"scrollbar-thumb":O()}],"scrollbar-track-color":[{"scrollbar-track":O()}],"scrollbar-gutter":[{"scrollbar-gutter":[`auto`,`stable`,`both`]}],"scrollbar-w":[{scrollbar:[`auto`,`thin`,`none`]}],"scroll-m":[{"scroll-m":S()}],"scroll-mx":[{"scroll-mx":S()}],"scroll-my":[{"scroll-my":S()}],"scroll-ms":[{"scroll-ms":S()}],"scroll-me":[{"scroll-me":S()}],"scroll-mbs":[{"scroll-mbs":S()}],"scroll-mbe":[{"scroll-mbe":S()}],"scroll-mt":[{"scroll-mt":S()}],"scroll-mr":[{"scroll-mr":S()}],"scroll-mb":[{"scroll-mb":S()}],"scroll-ml":[{"scroll-ml":S()}],"scroll-p":[{"scroll-p":S()}],"scroll-px":[{"scroll-px":S()}],"scroll-py":[{"scroll-py":S()}],"scroll-ps":[{"scroll-ps":S()}],"scroll-pe":[{"scroll-pe":S()}],"scroll-pbs":[{"scroll-pbs":S()}],"scroll-pbe":[{"scroll-pbe":S()}],"scroll-pt":[{"scroll-pt":S()}],"scroll-pr":[{"scroll-pr":S()}],"scroll-pb":[{"scroll-pb":S()}],"scroll-pl":[{"scroll-pl":S()}],"snap-align":[{snap:[`start`,`end`,`center`,`align-none`]}],"snap-stop":[{snap:[`normal`,`always`]}],"snap-type":[{snap:[`none`,`x`,`y`,`both`]}],"snap-strictness":[{snap:[`mandatory`,`proximity`]}],touch:[{touch:[`auto`,`none`,`manipulation`]}],"touch-x":[{"touch-pan":[`x`,`left`,`right`]}],"touch-y":[{"touch-pan":[`y`,`up`,`down`]}],"touch-pz":[`touch-pinch-zoom`],select:[{select:[`none`,`text`,`all`,`auto`]}],"will-change":[{"will-change":[`auto`,`scroll`,`contents`,`transform`,L,I]}],fill:[{fill:[`none`,...O()]}],"stroke-w":[{stroke:[F,bt,pt,mt]}],stroke:[{stroke:[`none`,...O()]}],"forced-color-adjust":[{"forced-color-adjust":[`auto`,`none`]}]},conflictingClassGroups:{"container-named":[`container-type`],overflow:[`overflow-x`,`overflow-y`],overscroll:[`overscroll-x`,`overscroll-y`],inset:[`inset-x`,`inset-y`,`inset-bs`,`inset-be`,`start`,`end`,`top`,`right`,`bottom`,`left`],"inset-x":[`right`,`left`],"inset-y":[`top`,`bottom`],flex:[`basis`,`grow`,`shrink`],gap:[`gap-x`,`gap-y`],p:[`px`,`py`,`ps`,`pe`,`pbs`,`pbe`,`pt`,`pr`,`pb`,`pl`],px:[`pr`,`pl`],py:[`pt`,`pb`],m:[`mx`,`my`,`ms`,`me`,`mbs`,`mbe`,`mt`,`mr`,`mb`,`ml`],mx:[`mr`,`ml`],my:[`mt`,`mb`],size:[`w`,`h`],"font-size":[`leading`],"fvn-normal":[`fvn-ordinal`,`fvn-slashed-zero`,`fvn-figure`,`fvn-spacing`,`fvn-fraction`],"fvn-ordinal":[`fvn-normal`],"fvn-slashed-zero":[`fvn-normal`],"fvn-figure":[`fvn-normal`],"fvn-spacing":[`fvn-normal`],"fvn-fraction":[`fvn-normal`],"line-clamp":[`display`,`overflow`],rounded:[`rounded-s`,`rounded-e`,`rounded-t`,`rounded-r`,`rounded-b`,`rounded-l`,`rounded-ss`,`rounded-se`,`rounded-ee`,`rounded-es`,`rounded-tl`,`rounded-tr`,`rounded-br`,`rounded-bl`],"rounded-s":[`rounded-ss`,`rounded-es`],"rounded-e":[`rounded-se`,`rounded-ee`],"rounded-t":[`rounded-tl`,`rounded-tr`],"rounded-r":[`rounded-tr`,`rounded-br`],"rounded-b":[`rounded-br`,`rounded-bl`],"rounded-l":[`rounded-tl`,`rounded-bl`],"border-spacing":[`border-spacing-x`,`border-spacing-y`],"border-w":[`border-w-x`,`border-w-y`,`border-w-s`,`border-w-e`,`border-w-bs`,`border-w-be`,`border-w-t`,`border-w-r`,`border-w-b`,`border-w-l`],"border-w-x":[`border-w-r`,`border-w-l`],"border-w-y":[`border-w-t`,`border-w-b`],"border-color":[`border-color-x`,`border-color-y`,`border-color-s`,`border-color-e`,`border-color-bs`,`border-color-be`,`border-color-t`,`border-color-r`,`border-color-b`,`border-color-l`],"border-color-x":[`border-color-r`,`border-color-l`],"border-color-y":[`border-color-t`,`border-color-b`],translate:[`translate-x`,`translate-y`,`translate-none`],"translate-none":[`translate`,`translate-x`,`translate-y`,`translate-z`],"scroll-m":[`scroll-mx`,`scroll-my`,`scroll-ms`,`scroll-me`,`scroll-mbs`,`scroll-mbe`,`scroll-mt`,`scroll-mr`,`scroll-mb`,`scroll-ml`],"scroll-mx":[`scroll-mr`,`scroll-ml`],"scroll-my":[`scroll-mt`,`scroll-mb`],"scroll-p":[`scroll-px`,`scroll-py`,`scroll-ps`,`scroll-pe`,`scroll-pbs`,`scroll-pbe`,`scroll-pt`,`scroll-pr`,`scroll-pb`,`scroll-pl`],"scroll-px":[`scroll-pr`,`scroll-pl`],"scroll-py":[`scroll-pt`,`scroll-pb`],touch:[`touch-x`,`touch-y`,`touch-pz`],"touch-x":[`touch`],"touch-y":[`touch`],"touch-pz":[`touch`]},conflictingClassGroupModifiers:{"font-size":[`leading`]},postfixLookupClassGroups:[`container-type`],orderSensitiveModifiers:[`*`,`**`,`after`,`backdrop`,`before`,`details-content`,`file`,`first-letter`,`first-line`,`marker`,`placeholder`,`selection`]}});function Rt(...e){return Lt(pe(e))}function zt({className:e,...t}){return(0,M.jsx)(`table`,{"data-slot":`table`,className:Rt(`w-full border-collapse text-sm`,e),...t})}function Bt({className:e,...t}){return(0,M.jsx)(`thead`,{"data-slot":`table-header`,className:e,...t})}function R({className:e,...t}){return(0,M.jsx)(`tbody`,{"data-slot":`table-body`,className:e,...t})}function z({className:e,...t}){return(0,M.jsx)(`tr`,{"data-slot":`table-row`,className:Rt(`border-b transition-colors hover:bg-muted/45`,e),...t})}function B({className:e,...t}){return(0,M.jsx)(`th`,{"data-slot":`table-head`,className:Rt(`bg-secondary px-3 py-3 text-left align-middle text-xs font-extrabold text-muted-foreground`,e),...t})}function V({className:e,...t}){return(0,M.jsx)(`td`,{"data-slot":`table-cell`,className:Rt(`px-3 py-3 align-middle text-sm`,e),...t})}function Vt({label:e,scope:t,sort:n,sortKey:r,onSort:i}){let a=n.key===r,o=n.direction===`asc`?`↑`:`↓`;return(0,M.jsx)(`button`,{type:`button`,"data-sort-scope":t,"data-sort-key":r,className:`text-left font-[inherit] text-inherit hover:text-foreground`,onClick:()=>i(t,r),children:a?`${e} ${o}`:e})}function H({className:e,label:t,scope:n,sort:r,sortKey:i,onSort:a}){return(0,M.jsx)(B,{className:e,"aria-sort":r.key===i?r.direction===`asc`?`ascending`:`descending`:`none`,children:(0,M.jsx)(Vt,{label:t,scope:n,sort:r,sortKey:i,onSort:a})})}var Ht=e=>typeof e==`boolean`?`${e}`:e===0?`0`:e,Ut=pe,Wt=(e,t)=>n=>{if(t?.variants==null)return Ut(e,n?.class,n?.className);let{variants:r,defaultVariants:i}=t,a=Object.keys(r).map(e=>{let t=n?.[e],a=i?.[e];if(t===null)return null;let o=Ht(t)||Ht(a);return r[e][o]}),o=n&&Object.entries(n).reduce((e,t)=>{let[n,r]=t;return r===void 0||(e[n]=r),e},{});return Ut(e,a,t?.compoundVariants?.reduce((e,t)=>{let{class:n,className:r,...a}=t;return Object.entries(a).every(e=>{let[t,n]=e;return Array.isArray(n)?n.includes({...i,...o}[t]):{...i,...o}[t]===n})?[...e,n,r]:e},[]),n?.class,n?.className)},Gt=Wt(`inline-flex min-h-[22px] items-center rounded-full border px-2 py-0.5 text-[11px] font-extrabold uppercase leading-tight`,{variants:{variant:{neutral:`border-border bg-secondary text-muted-foreground`,succeeded:`border-emerald-400/35 bg-emerald-500/15 text-emerald-300`,failed:`border-red-400/40 bg-red-500/15 text-red-300`,dead:`border-red-400/40 bg-red-500/15 text-red-300`,missing:`border-red-400/40 bg-red-500/15 text-red-300`,running:`border-amber-400/40 bg-amber-500/15 text-amber-300`,queued:`border-teal-400/40 bg-teal-500/15 text-teal-200`,canceled:`border-border bg-secondary text-muted-foreground`}},defaultVariants:{variant:`neutral`}});function U({className:e,variant:t,...n}){return(0,M.jsx)(`span`,{"data-slot":`badge`,className:Rt(Gt({variant:t,className:e})),...n})}var Kt=Wt(`inline-flex min-h-9 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md border text-sm font-semibold transition-colors focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0`,{variants:{variant:{default:`border-primary bg-primary text-primary-foreground hover:bg-primary/90`,secondary:`border-border bg-secondary text-secondary-foreground hover:bg-secondary/80`,outline:`border-border bg-background hover:bg-accent hover:text-accent-foreground`,ghost:`border-transparent hover:bg-accent hover:text-accent-foreground`,destructive:`border-destructive bg-destructive text-white hover:bg-destructive/90`},size:{default:`h-9 px-4 py-2`,sm:`h-8 rounded-md px-3 text-xs`,icon:`size-9`}},defaultVariants:{variant:`secondary`,size:`default`}});function W({className:e,variant:t,size:n,type:r=`button`,...i}){return(0,M.jsx)(`button`,{"data-slot":`button`,type:r,className:Rt(Kt({variant:t,size:n,className:e})),...i})}function G({className:e,...t}){return(0,M.jsx)(`div`,{"data-slot":`card`,className:Rt(`rounded-lg border bg-card text-card-foreground shadow-[0_18px_44px_rgb(0_0_0/0.22)]`,e),...t})}function qt({className:e,...t}){return(0,M.jsx)(`div`,{"data-slot":`card-header`,className:Rt(`flex items-center justify-between gap-3 border-b px-4 py-3`,e),...t})}function Jt({className:e,...t}){return(0,M.jsx)(`h2`,{"data-slot":`card-title`,className:Rt(`text-[15px] font-bold`,e),...t})}function Yt({className:e,...t}){return(0,M.jsx)(`div`,{"data-slot":`card-content`,className:Rt(`p-4`,e),...t})}function K({className:e,type:t,...n}){return(0,M.jsx)(`input`,{"data-slot":`input`,type:t,className:Rt(`flex min-h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50`,e),...n})}function q({className:e,...t}){return(0,M.jsx)(`label`,{"data-slot":`label`,className:Rt(`grid gap-1.5 text-xs font-bold text-muted-foreground`,e),...t})}function J({className:e,...t}){return(0,M.jsx)(`select`,{"data-slot":`select`,className:Rt(`flex min-h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-xs transition-colors focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50`,e),...t})}var Xt={pending:`Needs review`,selected:`Assigned to onboarder`,reachingout:`Reaching out`,awaitingcontribution:`Awaiting contribution`,onboarded:`Onboarded`,waitlist:`Waitlist`,rejected:`Rejected`};function Y(e){if(!e)return``;let t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString(void 0,{year:`numeric`,month:`short`,day:`numeric`,hour:`2-digit`,minute:`2-digit`})}function Zt(e,t=new Date){if(!e)return null;let n=new Date(e);if(Number.isNaN(n.getTime()))return null;let r=t.getTime()-n.getTime();return r<0?0:Math.floor(r/864e5)}function Qt(e){return e==null?``:JSON.stringify(e,null,2)}function $t(e){return[`succeeded`,`dead`,`canceled`].includes(String(e||``).trim().toLowerCase())}function en(e){return e===`llm`?`LLM`:e===`heuristic`?`Keyword fallback`:`Unknown`}function tn(e){return e.onboarding_state||e.onboardingState||e.cOnboardingState||``}function nn(e){let t=String(e||``).trim();if(!t)return`No status`;let n=t.toLowerCase();return Xt[n]?Xt[n]:t.replace(/[-_]+/g,` `).replace(/\s+/g,` `).trim().replace(/\b\w/g,e=>e.toUpperCase())}function rn(e){let t=String(e||``).trim().toLowerCase();return!t||t===`pending`?`neutral`:t===`selected`?`queued`:t===`rejected`?`failed`:t===`onboarded`?`succeeded`:t===`waitlist`?`running`:`queued`}function an(e){let t=String(e||``).trim();return!t||t.toLowerCase()===`none`?``:t}function on(e){let t=String(e||``).trim();return t?/^https?:\/\//i.test(t)?t:`https://${t.replace(/^\/+/,``)}`:``}function sn(e){try{return new URL(on(e))}catch{return null}}function cn(e,t){let n=e.toLowerCase();return n===t||n.endsWith(`.${t}`)}function ln(e){return e.split(`/`).filter(Boolean).map(e=>encodeURIComponent(e)).join(`/`)}function un(e){let t=String(e||``).trim();if(!t)return``;let n=sn(t);if(n&&cn(n.hostname,`linkedin.com`))return n.href;if(/^https?:\/\//i.test(t))return``;let r=t.replace(/^@/,``).replace(/^\/+|\/+$/g,``).replace(/^in\//i,``);return r?`https://www.linkedin.com/in/${ln(r)}`:``}function dn(e){let t=String(e||``).trim().replace(/^@/,``);if(!t)return``;let n=sn(t);if(n&&cn(n.hostname,`github.com`))return n.href;if(/^https?:\/\//i.test(t))return``;let r=t.replace(/^\/+|\/+$/g,``);return r?`https://github.com/${ln(r)}`:``}var fn=[{value:`part_time`,label:`Part-time / contract`},{value:`full_time`,label:`Full-time`},{value:`part_time_or_full_time`,label:`Part-time or full-time`},{value:`unknown`,label:`Unknown`}],pn=[{category:`CRM`,label:`CRM`,description:`EspoCRM connection settings used by the API, worker, and Discord bot.`},{category:`Projects`,label:`Projects`,description:`ERPNext credentials and project workflow settings.`},{category:`Onboarding`,label:`Onboarding`,description:`Editable onboarding integrations such as DocuSeal, Outline, and onboarding email SMTP.`},{category:`Newsletter`,label:`Newsletter`,description:`Brevo, Keila, and recurring 508 members audience sync settings.`},{category:`AI`,label:`AI Providers`,description:`Provider credentials, base URLs, and model defaults.`},{category:`Agent`,label:`Agent Runtime`,description:`Planner, fallback, and tiered model routing for agent workflows.`},{category:`Observability`,label:`Observability`,description:`Telemetry and request tracing integrations.`},{category:`Intake`,label:`Intake`,description:`Resume and mailbox intake limits and parser defaults.`},{category:`Operations`,label:`Operations`,description:`Queue, sync, GitHub, and notification behavior.`},{category:`Legacy`,label:`Legacy`,description:`Older integrations retained for compatibility.`}],mn=new Map(pn.map((e,t)=>[e.category,{...e,index:t}]));function hn(e){return`configurationGroup-${e.replace(/[^a-zA-Z0-9_-]+/g,`-`)}`}function gn(e=window.location.hash){return e.replace(/^#/,``)===`job-channels`?`job-channels`:`settings`}function _n(e){let t=e===`job-channels`?`#job-channels`:`#settings`;window.history.replaceState(window.history.state,``,`${window.location.pathname}${window.location.search}${t}`)}function vn(e){return fn.some(t=>t.value===e)?e:`unknown`}function yn(e){return e.key.startsWith(`ONBOARDING_EMAIL_`)||e.is_secret||e.value_type===`url`||e.key.endsWith(`_MODEL`)||e.key.endsWith(`_API_USER`)||e.key.endsWith(`_BASE_URL`)}function bn({items:e,loading:t,canWrite:n,onRefresh:r,jobChannels:i,availableJobChannels:a,onRefreshJobChannels:o,onSave:s,onClear:c,onSaveJobChannel:u,onDeleteJobChannel:d,focusCategory:f,focusNonce:p}){let[m,h]=(0,l.useState)(gn),[g,_]=(0,l.useState)(`All`),[v,y]=(0,l.useState)(``),[b,x]=(0,l.useState)(``),[ee,te]=(0,l.useState)(`part_time`),[w,ne]=(0,l.useState)({}),[re,ie]=(0,l.useState)({}),oe=(0,l.useMemo)(()=>new Set(i.map(e=>e.channel_id)),[i]),se=(0,l.useMemo)(()=>{let e=new Map;for(let t of i)e.set(t.channel_id,t);for(let t of a)e.set(t.channel_id,{...e.get(t.channel_id),...t});return Array.from(e.values()).sort((e,t)=>(e.channel_name||e.channel_id).localeCompare(t.channel_name||t.channel_id))},[a,i]),T=se.filter(e=>!oe.has(e.channel_id)),E=(0,l.useMemo)(()=>{let t=new Set(e.map(e=>e.category)),n=pn.filter(e=>t.has(e.category)),r=Array.from(t).filter(e=>!mn.has(e)).sort().map(e=>({category:e,label:e,description:`Additional runtime settings.`}));return n.concat(r)},[e]),D=(0,l.useMemo)(()=>{let t=new Map;for(let n of e){if(g!==`All`&&n.category!==g)continue;let e=t.get(n.category)??[];e.push(n),t.set(n.category,e)}return Array.from(t.entries()).map(([e,t])=>{let n=mn.get(e),r=t.sort((e,t)=>Number(!yn(e))-Number(!yn(t))||e.label.localeCompare(t.label)),i=r.filter(yn),a=r.filter(e=>!yn(e));return{category:e,label:n?.label??e,description:n?.description??`Additional runtime settings.`,order:n?.index??pn.length,primaryItems:i.length?i:r,advancedItems:i.length?a:[],items:r}}).sort((e,t)=>e.order-t.order||e.label.localeCompare(t.label))},[e,g]),O=(0,l.useMemo)(()=>({configured:e.filter(e=>e.configured).length,envLocked:e.filter(e=>e.env_locked).length,missing:e.filter(e=>!e.configured).length}),[e]),ce=D.reduce((e,t)=>e+t.items.length,0);(0,l.useEffect)(()=>{let e=()=>h(gn());return window.addEventListener(`hashchange`,e),()=>window.removeEventListener(`hashchange`,e)},[]),(0,l.useEffect)(()=>{b&&se.some(e=>e.channel_id===b)||x((T[0]||se[0])?.channel_id||``)},[se,b,T]),(0,l.useEffect)(()=>{let e=se.find(e=>e.channel_id===b);if(!e)return;let t=vn(String(e.posting_type||``));te(oe.has(e.channel_id)?t:`part_time`)},[se,oe,b]),(0,l.useEffect)(()=>{ne(Object.fromEntries(e.map(e=>[e.key,e.is_secret?``:String(e.value??``)])))},[e]),(0,l.useEffect)(()=>{g!==`All`&&!e.some(e=>e.category===g)&&_(`All`)},[e,g]),(0,l.useEffect)(()=>{if(!f||!e.some(e=>e.category===f))return;h(`settings`),_n(`settings`),_(f),y(f);let t=window.requestAnimationFrame?.(()=>{let e=document.getElementById(hn(f));typeof e?.scrollIntoView==`function`&&e.scrollIntoView({block:`start`,behavior:`smooth`})}),n=window.setTimeout(()=>y(``),4e3);return()=>{t!==void 0&&window.cancelAnimationFrame?.(t),window.clearTimeout(n)}},[f,p,e]);function le(e){return e.source===`env`?`ENV`:e.source===`database`?`DB`:`Default`}function ue(e){h(e),_n(e)}function de(e){return fn.find(t=>t.value===e)?.label??e}function k(e){let t=e.channel_name?`#${e.channel_name}`:`#${e.channel_id}`;return oe.has(e.channel_id)?`${t} (registered)`:t}function A(){let e=new Uint8Array(32);return window.crypto.getRandomValues(e),Array.from(e,e=>e.toString(16).padStart(2,`0`)).join(``)}async function j(e){let t=A();ne(n=>({...n,[e.key]:t})),await s(e.key,t)&&ie(n=>({...n,[e.key]:t}))}async function fe(e){let t=re[e];if(t){if(navigator.clipboard?.writeText)try{await navigator.clipboard.writeText(t);return}catch{}document.getElementById(`generatedSecret-${e}`)?.select()}}function pe(e){let r=w[e.key]??``,i=!n||e.env_locked||t[`configuration:${e.key}`];return e.value_type===`bool`?(0,M.jsxs)(J,{"aria-label":`${e.label} value`,value:r,disabled:i,onChange:t=>ne(n=>({...n,[e.key]:t.target.value})),children:[(0,M.jsx)(`option`,{value:``,children:`Default`}),(0,M.jsx)(`option`,{value:`true`,children:`True`}),(0,M.jsx)(`option`,{value:`false`,children:`False`})]}):(0,M.jsx)(K,{"aria-label":`${e.label} value`,value:r,type:e.is_secret?`password`:e.value_type===`int`?`number`:`text`,inputMode:e.value_type===`int`||e.value_type===`float`?`numeric`:`text`,placeholder:e.is_secret?`Set new value`:``,autoComplete:`off`,disabled:i,onChange:t=>ne(n=>({...n,[e.key]:t.target.value}))})}function me(e,t,n){return(0,M.jsx)(`div`,{className:`overflow-x-auto`,children:(0,M.jsxs)(zt,{id:`configurationTable-${n}`,className:`min-w-[980px]`,"aria-label":`${e} configuration settings`,children:[(0,M.jsx)(Bt,{children:(0,M.jsxs)(z,{children:[(0,M.jsx)(B,{className:`w-[26%]`,children:`Setting`}),(0,M.jsx)(B,{className:`w-[12%]`,children:`Source`}),(0,M.jsx)(B,{className:`w-[18%]`,children:`Active`}),(0,M.jsx)(B,{className:`w-[29%]`,children:`Value`}),(0,M.jsx)(B,{className:`w-[15%]`,children:`Actions`})]})}),(0,M.jsx)(R,{id:`configurationBody-${n}`,children:t.map(e=>he(e))})]})})}function he(e){let r=t[`configuration:${e.key}`],i=n&&!e.env_locked&&!r,a=w[e.key]??``,o=!e.is_secret&&!a.trim(),l=re[e.key]||``,u=e.key===`ONBOARDING_TALLY_WEBHOOK_SIGNING_SECRET`;return(0,M.jsxs)(z,{children:[(0,M.jsx)(V,{children:(0,M.jsxs)(`div`,{className:`grid gap-1`,children:[(0,M.jsx)(`strong`,{children:e.label}),(0,M.jsx)(`span`,{className:`font-mono text-xs text-muted-foreground`,children:e.key}),(0,M.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:e.description}),e.restart_required?(0,M.jsx)(`div`,{children:(0,M.jsx)(U,{variant:`running`,children:`Restart`})}):null]})}),(0,M.jsx)(V,{children:(0,M.jsxs)(`div`,{className:`grid gap-1.5`,children:[(0,M.jsx)(U,{variant:e.source===`env`?`running`:`neutral`,children:le(e)}),e.env_locked?(0,M.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`Environment locked`}):null]})}),(0,M.jsx)(V,{children:(0,M.jsxs)(`div`,{className:`grid gap-1`,children:[(0,M.jsx)(U,{variant:e.configured?`succeeded`:`missing`,children:e.configured?`Configured`:`Missing`}),e.is_secret?(0,M.jsx)(`span`,{className:`font-mono text-xs text-muted-foreground`,children:e.masked_value||(e.configured?`Hidden`:`No secret`)}):(0,M.jsx)(`span`,{className:`break-words text-xs text-muted-foreground`,children:String(e.value??``)||`Default`}),e.is_secret&&e.secret_encryption_configured===!1?(0,M.jsx)(`span`,{className:`text-xs text-red-300`,children:`Encryption key missing`}):null]})}),(0,M.jsx)(V,{children:(0,M.jsxs)(`div`,{className:`grid gap-2`,children:[pe(e),u&&l?(0,M.jsxs)(`div`,{className:`grid gap-2 rounded-md border bg-secondary/30 p-2 text-xs`,children:[(0,M.jsx)(`span`,{className:`font-extrabold`,children:`Copy this secret into Tally now.`}),(0,M.jsx)(K,{id:`generatedSecret-${e.key}`,value:l,readOnly:!0,className:`font-mono`,"aria-label":`Generated Tally webhook signing secret`}),(0,M.jsx)(`span`,{className:`text-muted-foreground`,children:`It is only shown until this page refreshes or you dismiss it.`})]}):null]})}),(0,M.jsx)(V,{children:(0,M.jsxs)(`div`,{className:`flex flex-wrap justify-end gap-2`,children:[u?(0,M.jsx)(W,{type:`button`,size:`sm`,variant:`outline`,onClick:()=>void j(e),disabled:!i||e.secret_encryption_configured===!1||!window.crypto?.getRandomValues,children:`Generate`}):null,u&&l?(0,M.jsxs)(M.Fragment,{children:[(0,M.jsx)(W,{type:`button`,size:`sm`,variant:`outline`,onClick:()=>void fe(e.key),children:`Copy`}),(0,M.jsx)(W,{type:`button`,size:`sm`,variant:`ghost`,onClick:()=>ie(t=>{let n={...t};return delete n[e.key],n}),children:`Hide`})]}):null,(0,M.jsx)(W,{type:`button`,size:`sm`,onClick:()=>s(e.key,a),disabled:!i||e.is_secret&&!a.trim()||o||e.is_secret&&e.secret_encryption_configured===!1,children:`Save`}),(0,M.jsx)(W,{type:`button`,size:`sm`,variant:`outline`,onClick:()=>c(e.key),disabled:!i||e.source!==`database`,children:`Clear`})]})})]},e.key)}function ge(){let e=se.find(e=>e.channel_id===b),r=b?oe.has(b):!1,a=b?t[`jobPostChannel:${b}`]:!1;return(0,M.jsxs)(`div`,{className:`grid gap-4`,children:[(0,M.jsx)(`section`,{className:`grid gap-3 sm:grid-cols-3`,"aria-label":`Job channel summary`,children:[[`Registered`,i.length],[`Forum channels`,se.length],[`Available to add`,T.length]].map(([e,t])=>(0,M.jsxs)(`div`,{className:`rounded-md border bg-background p-3`,children:[(0,M.jsx)(`span`,{className:`text-[11px] font-extrabold uppercase text-muted-foreground`,children:e}),(0,M.jsx)(`strong`,{className:`mt-1 block text-xl`,children:t})]},e))}),(0,M.jsxs)(`section`,{className:`grid gap-3 rounded-md border bg-background p-4`,children:[(0,M.jsxs)(`div`,{className:`grid gap-3 lg:grid-cols-[minmax(0,1.5fr)_minmax(180px,0.7fr)_auto]`,children:[(0,M.jsxs)(J,{"aria-label":`Job channel to register`,value:b,disabled:!n||t.jobPostChannels||se.length===0,onChange:e=>x(e.target.value),children:[se.length===0?(0,M.jsx)(`option`,{value:``,children:`No Discord forum channels loaded`}):null,se.map(e=>(0,M.jsx)(`option`,{value:e.channel_id,children:k(e)},e.channel_id))]}),(0,M.jsx)(J,{"aria-label":`New job channel posting type`,value:ee,disabled:!n||t.jobPostChannels||se.length===0,onChange:e=>te(e.target.value),children:fn.map(e=>(0,M.jsx)(`option`,{value:e.value,children:e.label},e.value))}),(0,M.jsxs)(W,{type:`button`,onClick:()=>void u(b,ee),disabled:!n||!b||a,children:[(0,M.jsx)(S,{}),r?`Update channel`:`Register channel`]})]}),e?(0,M.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[`Selected channel ID: `,e.channel_id]}):null]}),(0,M.jsx)(N,{hidden:i.length!==0,children:`No job channels registered.`}),i.length?(0,M.jsx)(`div`,{className:`overflow-x-auto rounded-md border`,children:(0,M.jsxs)(zt,{className:`min-w-[760px]`,"aria-label":`Registered job channels`,children:[(0,M.jsx)(Bt,{children:(0,M.jsxs)(z,{children:[(0,M.jsx)(B,{className:`w-[32%]`,children:`Channel`}),(0,M.jsx)(B,{className:`w-[24%]`,children:`Posting type`}),(0,M.jsx)(B,{className:`w-[24%]`,children:`Tags`}),(0,M.jsx)(B,{className:`w-[20%]`,children:`Actions`})]})}),(0,M.jsx)(R,{children:i.map(e=>{let r=t[`jobPostChannel:${e.channel_id}`],i=vn(String(e.posting_type||``));return(0,M.jsxs)(z,{children:[(0,M.jsx)(V,{children:(0,M.jsxs)(`div`,{className:`grid gap-1`,children:[(0,M.jsx)(`strong`,{children:e.channel_name?`#${e.channel_name}`:`#${e.channel_id}`}),(0,M.jsx)(`span`,{className:`font-mono text-xs text-muted-foreground`,children:e.channel_id})]})}),(0,M.jsx)(V,{children:(0,M.jsx)(J,{"aria-label":`${e.channel_name||e.channel_id} posting type`,value:i,disabled:!n||r,onChange:t=>void u(e.channel_id,t.target.value),children:fn.map(e=>(0,M.jsx)(`option`,{value:e.value,children:e.label},e.value))})}),(0,M.jsx)(V,{children:(0,M.jsxs)(`div`,{className:`flex flex-wrap gap-1.5`,children:[e.requires_tag?(0,M.jsx)(U,{variant:`running`,children:`Requires tag`}):null,(0,M.jsxs)(U,{variant:`neutral`,children:[(e.available_tags||[]).length,` tags`]}),(0,M.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:de(i)})]})}),(0,M.jsx)(V,{children:(0,M.jsxs)(W,{type:`button`,size:`sm`,variant:`destructive`,title:`Deregister channel`,onClick:()=>d(e.channel_id),disabled:!n||r,children:[(0,M.jsx)(ae,{}),`Deregister`]})})]},e.channel_id)})})]})}):null]})}return(0,M.jsxs)(`div`,{className:`grid gap-4`,children:[(0,M.jsxs)(G,{children:[(0,M.jsxs)(qt,{children:[(0,M.jsxs)(`div`,{className:`grid gap-3`,children:[(0,M.jsx)(Jt,{children:`Configuration`}),(0,M.jsxs)(`div`,{className:`inline-flex w-fit rounded-md border bg-background p-1`,role:`tablist`,"aria-label":`Configuration tabs`,children:[(0,M.jsx)(W,{id:`configurationSettingsTab`,type:`button`,size:`sm`,variant:m===`settings`?`default`:`ghost`,"aria-pressed":m===`settings`,onClick:()=>ue(`settings`),children:`Settings`}),(0,M.jsxs)(W,{id:`configurationJobChannelsTab`,type:`button`,size:`sm`,variant:m===`job-channels`?`default`:`ghost`,"aria-pressed":m===`job-channels`,onClick:()=>ue(`job-channels`),children:[`Job channels`,(0,M.jsx)(`span`,{className:`font-mono text-[11px]`,children:i.length})]})]})]}),(0,M.jsxs)(W,{id:`refreshConfiguration`,type:`button`,variant:`outline`,onClick:m===`job-channels`?o:r,disabled:m===`job-channels`?t.jobPostChannels:t.configuration,children:[(0,M.jsx)(C,{}),`Refresh`]})]}),m===`settings`?(0,M.jsxs)(Yt,{className:`grid gap-4`,children:[(0,M.jsx)(`section`,{className:`grid gap-3 sm:grid-cols-2 lg:grid-cols-4`,"aria-label":`Configuration summary`,children:[[`Total`,e.length],[`Configured`,O.configured],[`Missing`,O.missing],[`Env locked`,O.envLocked]].map(([e,t])=>(0,M.jsxs)(`div`,{className:`rounded-md border bg-background p-3`,children:[(0,M.jsx)(`span`,{className:`text-[11px] font-extrabold uppercase text-muted-foreground`,children:e}),(0,M.jsx)(`strong`,{className:`mt-1 block text-xl`,children:t})]},e))}),(0,M.jsxs)(`section`,{className:`flex flex-wrap gap-2`,"aria-label":`Configuration groups`,children:[(0,M.jsxs)(W,{type:`button`,size:`sm`,variant:g===`All`?`default`:`outline`,"aria-pressed":g===`All`,onClick:()=>_(`All`),children:[`All groups`,(0,M.jsx)(`span`,{className:`font-mono text-[11px]`,children:e.length})]}),E.map(t=>{let n=e.filter(e=>e.category===t.category).length;return(0,M.jsxs)(W,{type:`button`,size:`sm`,variant:g===t.category?`default`:`outline`,"aria-pressed":g===t.category,onClick:()=>_(t.category),children:[t.label,(0,M.jsx)(`span`,{className:`font-mono text-[11px]`,children:n})]},t.category)})]})]}):(0,M.jsx)(Yt,{children:ge()})]}),m===`settings`?(0,M.jsx)(N,{hidden:ce!==0,children:`No configuration entries found.`}):null,m===`settings`?D.map(e=>{let t=e.items.filter(e=>e.configured).length,n=e.items.length-t,r=e.items.some(e=>e.restart_required);return(0,M.jsxs)(G,{id:hn(e.category),className:Rt(`scroll-mt-4 transition-shadow`,v===e.category&&`ring-2 ring-primary ring-offset-2 ring-offset-background`),children:[(0,M.jsxs)(qt,{className:`items-start`,children:[(0,M.jsxs)(`div`,{className:`grid gap-1`,children:[(0,M.jsx)(Jt,{children:e.label}),(0,M.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:e.description})]}),(0,M.jsxs)(`div`,{className:`flex flex-wrap justify-end gap-1.5`,children:[(0,M.jsxs)(U,{variant:`neutral`,children:[e.items.length,` settings`]}),(0,M.jsxs)(U,{variant:n?`missing`:`succeeded`,children:[t,` configured`]}),r?(0,M.jsx)(U,{variant:`running`,children:`Restart`}):null]})]}),me(e.label,e.primaryItems,e.category),e.advancedItems.length?(0,M.jsxs)(`details`,{className:`border-t bg-background/40`,children:[(0,M.jsxs)(`summary`,{className:`flex min-h-11 cursor-pointer items-center justify-between gap-3 px-4 py-3 text-sm font-extrabold`,children:[(0,M.jsx)(`span`,{children:`Advanced`}),(0,M.jsxs)(`span`,{className:`font-mono text-xs text-muted-foreground`,children:[e.advancedItems.length,` settings`]})]}),(0,M.jsx)(`div`,{className:`border-t`,children:me(`${e.label} advanced`,e.advancedItems,`${e.category}-advanced`)})]}):null]},e.category)}):null]})}function xn(e){if(!e)return null;let t=Object.entries(e.providers||{}).map(([e,t])=>`${e}: ${t.would_sync??t.synced??0} would sync, ${t.skipped||0} skipped, ${t.failed||0} failed`),n=e.mailboxes_scanned??0,r=e.contacts_considered??0;return[`${n} mailbox${n===1?``:`es`}`,`${r} contact${r===1?``:`s`}`,...t].join(`; `)}function Sn(e){let t=Number(e||0);if(!Number.isFinite(t)||t<=0)return`Not configured`;if(t%86400==0){let e=t/86400;return`${e} day${e===1?``:`s`}`}if(t%3600==0){let e=t/3600;return`${e} hour${e===1?``:`s`}`}if(t%60==0){let e=t/60;return`${e} minute${e===1?``:`s`}`}return`${t} second${t===1?``:`s`}`}function Cn(e){let t=e?.result;if(!t||typeof t!=`object`||Array.isArray(t))return[];let n=t.providers;return!n||typeof n!=`object`||Array.isArray(n)?[]:Object.entries(n).filter(e=>{let[,t]=e;return!!(t&&typeof t==`object`&&!Array.isArray(t))}).sort(([e],[t])=>e.localeCompare(t))}function wn(e){let t=e.status?.latest_job||null,n=e.status?.active_suppressed_email_count,r=e.status?.active_suppression_count,i=Cn(t),a=e.providerFilter?[...new Set([...e.providerOptions,e.providerFilter])].sort((e,t)=>e.localeCompare(t)):e.providerOptions,o=e.suppressions.reduce((e,t)=>{let n=t.source_provider||`unknown`;return e[n]=[...e[n]||[],t],e},{}),s=Object.entries(o).sort(([e],[t])=>e.localeCompare(t));return(0,M.jsxs)(M.Fragment,{children:[(0,M.jsxs)(G,{children:[(0,M.jsxs)(qt,{children:[(0,M.jsx)(Jt,{children:`Newsletter sync`}),(0,M.jsxs)(`div`,{className:`flex flex-wrap items-center justify-end gap-2`,children:[(0,M.jsxs)(W,{id:`refreshNewsletter`,type:`button`,variant:`outline`,onClick:e.onRefresh,disabled:e.loading.newsletterStatus||e.loading.newsletterSuppressions,children:[(0,M.jsx)(C,{}),`Refresh`]}),e.canSync?(0,M.jsxs)(W,{id:`syncNewsletters`,"data-permission":`people:sync`,type:`button`,onClick:e.onSync,disabled:e.loading.syncNewsletters,children:[(0,M.jsx)(C,{}),`Sync now`]}):null]})]}),(0,M.jsxs)(Yt,{children:[(0,M.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-4`,children:[(0,M.jsxs)(`div`,{className:`rounded-md border bg-background p-4`,children:[(0,M.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Scheduler`}),(0,M.jsx)(`strong`,{id:`newsletterScheduler`,className:`block text-2xl`,children:e.status?.scheduler_enabled?`Enabled`:`Disabled`})]}),(0,M.jsxs)(`div`,{className:`rounded-md border bg-background p-4`,children:[(0,M.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Interval`}),(0,M.jsx)(`strong`,{id:`newsletterInterval`,className:`block text-2xl`,children:Sn(e.status?.interval_seconds)})]}),(0,M.jsxs)(`div`,{className:`rounded-md border bg-background p-4`,children:[(0,M.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Suppressed emails`}),(0,M.jsx)(`strong`,{id:`newsletterSuppressedEmails`,className:`block text-2xl`,children:n??`Loading`})]}),(0,M.jsxs)(`div`,{className:`rounded-md border bg-background p-4`,children:[(0,M.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Suppression rows`}),(0,M.jsx)(`strong`,{id:`newsletterSuppressionRows`,className:`block text-2xl`,children:r??`Loading`})]})]}),(0,M.jsxs)(`div`,{className:`mt-4 grid gap-2 rounded-md border bg-secondary p-3 text-sm`,children:[(0,M.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[(0,M.jsx)(`span`,{className:`font-extrabold`,children:`Latest sync`}),t?(0,M.jsx)(U,{variant:t.status||`neutral`,children:t.status}):(0,M.jsx)(U,{variant:`neutral`,children:`No job found`})]}),t?(0,M.jsxs)(M.Fragment,{children:[(0,M.jsxs)(`div`,{className:`text-muted-foreground`,children:[t.type,` | updated `,Y(t.updated_at),` | attempts`,` `,t.attempts,`/`,t.max_attempts]}),t.last_error?(0,M.jsx)(`div`,{className:`text-red-700 dark:text-red-300`,children:t.last_error}):null,(0,M.jsx)(`div`,{className:`grid gap-2 md:grid-cols-2`,children:i.length?i.map(([e,t])=>{let n=t.statuses||{},r=Object.entries(n).sort(([e],[t])=>e.localeCompare(t)),i=t.synced??t.would_sync??0;return(0,M.jsxs)(`div`,{className:`grid gap-2 rounded-md border bg-background p-3`,children:[(0,M.jsxs)(`div`,{className:`flex flex-wrap items-center justify-between gap-2`,children:[(0,M.jsx)(`strong`,{children:e}),(0,M.jsxs)(`div`,{className:`flex flex-wrap gap-1`,children:[(0,M.jsxs)(U,{variant:`succeeded`,children:[i,` synced`]}),(0,M.jsxs)(U,{variant:`neutral`,children:[t.skipped||0,` skipped`]}),t.failed?(0,M.jsxs)(U,{variant:`failed`,children:[t.failed,` failed`]}):null]})]}),r.length?(0,M.jsx)(`div`,{className:`flex flex-wrap gap-1`,children:r.map(([e,t])=>(0,M.jsxs)(U,{variant:`neutral`,children:[e.replaceAll(`_`,` `),`: `,t]},e))}):null]},e)}):(0,M.jsx)(`div`,{className:`rounded-md border bg-background p-3 text-muted-foreground`,children:`No provider status details recorded for the latest sync.`})}),t.result?(0,M.jsx)(`pre`,{className:`max-h-40 overflow-auto rounded-md bg-background p-3 text-xs`,children:Qt(t.result)}):null]}):(0,M.jsx)(`div`,{className:`text-muted-foreground`,children:`No 508 members newsletter sync job was found in the last 90 days.`})]})]})]}),(0,M.jsxs)(G,{children:[(0,M.jsxs)(qt,{children:[(0,M.jsx)(Jt,{children:`Newsletter suppressions`}),(0,M.jsxs)(`div`,{className:`flex flex-wrap items-center justify-end gap-3`,children:[(0,M.jsxs)(q,{className:`min-w-44`,children:[`Provider`,(0,M.jsxs)(J,{id:`newsletterProviderFilter`,value:e.providerFilter,onChange:t=>e.onProviderFilterChange(t.target.value),children:[(0,M.jsx)(`option`,{value:``,children:`All providers`}),a.map(e=>(0,M.jsx)(`option`,{value:e,children:e},e))]})]}),(0,M.jsx)(`span`,{id:`newsletterSuppressionsStatus`,className:`text-sm text-muted-foreground`,children:e.loading.newsletterSuppressions?`Loading`:`${e.suppressions.length} shown`})]})]}),(0,M.jsx)(N,{hidden:e.suppressions.length!==0,children:`No active newsletter suppressions recorded.`}),(0,M.jsx)(`div`,{className:Rt(`grid gap-4`,e.suppressions.length===0&&`hidden`),children:s.map(([t,n])=>(0,M.jsxs)(`section`,{className:`grid gap-2`,"aria-label":`${t} suppressions`,children:[(0,M.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2 px-4 pt-4`,children:[(0,M.jsx)(U,{variant:`neutral`,children:t}),(0,M.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[n.length,` suppression`,n.length===1?``:`s`]})]}),(0,M.jsx)(`div`,{className:`overflow-x-auto`,children:(0,M.jsxs)(zt,{id:`newsletterSuppressionsTable-${t}`,className:`min-w-[900px]`,"aria-label":`${t} newsletter suppressions`,children:[(0,M.jsx)(Bt,{children:(0,M.jsxs)(z,{children:[(0,M.jsx)(H,{className:`w-[34%]`,label:`Email`,scope:`newsletter`,sort:e.sort,sortKey:`email`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[14%]`,label:`Source`,scope:`newsletter`,sort:e.sort,sortKey:`source_provider`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[22%]`,label:`Reason`,scope:`newsletter`,sort:e.sort,sortKey:`reason`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[15%]`,label:`First seen`,scope:`newsletter`,sort:e.sort,sortKey:`first_seen_at`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[15%]`,label:`Last seen`,scope:`newsletter`,sort:e.sort,sortKey:`last_seen_at`,onSort:(t,n)=>e.onSort(n)})]})}),(0,M.jsx)(R,{id:`newsletterSuppressionsBody-${t}`,children:n.map(e=>(0,M.jsxs)(z,{children:[(0,M.jsx)(V,{className:`font-mono text-sm`,children:e.email}),(0,M.jsx)(V,{children:(0,M.jsx)(U,{variant:`neutral`,children:e.source_provider})}),(0,M.jsx)(V,{children:e.reason.replaceAll(`_`,` `)}),(0,M.jsx)(V,{children:Y(e.first_seen_at)}),(0,M.jsx)(V,{children:Y(e.last_seen_at||e.updated_at)})]},`${e.email}-${e.source_provider}`))})]})})]},t))})]})]})}var Tn=2e3,En=90,Dn={people:`/dashboard/people`,gigs:`/dashboard/gigs`,projects:`/dashboard/projects`,onboarding:`/dashboard/onboarding`,newsletter:`/dashboard/newsletter`,jobs:`/dashboard/jobs`,agent:`/dashboard/agent`,audit:`/dashboard/audit`,configuration:`/dashboard/configuration`},On={people:`people:read`,gigs:`gigs:read`,projects:`projects:read`,onboarding:`onboarding:read`,newsletter:`people:sync`,jobs:`jobs:read`,agent:`audit:read`,audit:`audit:read`,configuration:`configuration:read`},kn={discord:{label:`Discord`,options:[[`linked`,`Linked`],[`missing`,`Missing`]]},email_508:{label:`508 email`,options:[[`present`,`Present`],[`missing`,`Missing`]]},resume:{label:`Resume`,options:[[`present`,`Present`],[`missing`,`Missing`]]},skills:{label:`Skills`,options:[[`present`,`Parsed`],[`missing`,`Not parsed`]]},sync_status:{label:`Sync status`,options:[[`active`,`Active`],[`conflict`,`Conflict`],[`missing_in_crm`,`Missing in CRM`]]}},An=[[`pending`,`Needs review`],[`selected`,`Assigned to onboarder`],[`reachingout`,`Reaching out`],[`awaitingcontribution`,`Awaiting contribution`],[`onboarded`,`Onboarded`],[`waitlist`,`Waitlist`],[`rejected`,`Rejected`]],jn=An.slice(0,4),Mn=new Set([`onboarded`,`waitlist`,`rejected`]);function Nn(e){return String(e||``).trim().toLowerCase().replace(/[-_\s]+/g,``)}var Pn=class extends Error{status;statusText;payload;url;method;constructor(e,t,n,r,i,a){super(e),this.name=`ApiRequestError`,this.status=t,this.statusText=n,this.payload=r,this.url=i,this.method=a}};function Fn(e,t){let n=e.detail;if(typeof n==`string`&&n.trim())return n;let r=e.error;return typeof r==`string`?r===`person_not_found`?`No CRM person, ERPNext user, or ERPNext supplier matched "${typeof e.person==`string`&&e.person.trim()?e.person:`that person`}". Try an email address or an exact name from CRM/ERPNext.`:r===`candidate_not_found`?`The selected person record is no longer available. Search again and choose one of the current matches.`:r===`invalid_crm_profile`?`Paste a valid CRM Contact profile URL or Contact id.`:r===`crm_profile_not_found`?`That CRM Contact profile was not found.`:r===`crm_profile_mismatch`?`CRM returned a different Contact than the profile requested. Check the profile URL and try again.`:r===`crm_profile_lookup_failed`?`CRM profile lookup failed. Try again after CRM is reachable.`:r===`ambiguous_person`?`Multiple people matched. Choose the matching person record.`:r||t:t}function In(e,t){return typeof e==`string`&&e.trim()?e:e instanceof Error&&e.message.trim()?e.message:t}function Ln(){return window.location.pathname.split(`/`).filter(Boolean)[1]||``}function Rn(){let e=Ln();return Object.hasOwn(Dn,e)?e:`people`}function zn(e=`gigs`){let[,t,n]=window.location.pathname.split(`/`).filter(Boolean);if(t!==e||!n)return``;try{return decodeURIComponent(n)}catch{return``}}function Bn(e=window.location.hash){return e.replace(/^#/,``)===`leads`?`leads`:`gigs`}function Vn(e){let t=e===`leads`?`#leads`:`#gigs`;window.history.replaceState(window.history.state,``,`${window.location.pathname}${window.location.search}${t}`)}async function X(e,t={}){let n=String(t.method||`GET`).toUpperCase(),r=new Headers(t.headers);r.set(`Accept`,`application/json`);let i;try{i=await fetch(e,{credentials:`same-origin`,...t,headers:r})}catch(t){throw new Pn(In(t,`Network request failed`),0,`Network request failed`,null,e,n)}if(i.status===401){let t=`${window.location.pathname}${window.location.search}`||`/dashboard`;throw window.location.assign(`/auth/login?next=${encodeURIComponent(t)}`),new Pn(`Session expired`,i.status,i.statusText,null,e,n)}if(!i.ok){let t=i.statusText,r=null;try{r=await i.json(),r&&typeof r==`object`&&(t=Fn(r,String(t||`Request failed`)))}catch{t=i.statusText}throw new Pn(typeof t==`string`?t:JSON.stringify(t),i.status,i.statusText,r,e,n)}return i.json()}function Hn(e,t,n){if(e===`gigs`){let e=t;if(n===`title`)return e.title||``;if(n===`status`)return e.status||``;if(n===`applications`)return Number(e.application_count||0);if(n===`activity`)return er(e)}if(e===`projects`){let e=t;if(n===`display_name`)return e.display_name||``;if(n===`customer`)return e.customer||``;if(n===`status`)return e.source_status||``;if(n===`roster_count`)return Number(e.roster_count||0);if(n===`modified`)return e.source_modified_at||e.last_synced_at||``}if(e===`onboarding`){let e=t,r=e.profile_status||{};if(n===`name`)return e.name||e.email_508||e.email||``;if(n===`onboarding_state`){let t=tn(e);return t.toLowerCase()===`pending`?`zzz-${t}`:t}if(n===`onboarder`)return e.onboarder||``;if(n===`updated`)return e.onboarding_updated_at||``;if(n===`profile_gaps`)return[!r.discord_linked,!r.latest_resume,Number(r.skills_count||0)<=0].filter(Boolean).length}if(e===`people`){let e=t,r=e.profile_status||{};if(n===`name`)return e.name||e.email_508||e.email||``;if(n===`status`)return[r.crm_active,r.is_member,r.discord_linked,r.email_508,r.latest_resume].filter(Boolean).length;if(n===`discord`)return e.discord_username||e.discord_user_id||``;if(n===`resume`)return e.latest_resume_name||e.latest_resume_id||``}if(e===`audit`){let e=t;if(n===`actor`)return e.actor_display_name||e.actor_subject||e.actor_provider||``}if(e===`newsletter`){let e=t;if(n===`email`)return e.email||``;if(n===`source_provider`)return e.source_provider||``;if(n===`reason`)return e.reason||``;if(n===`first_seen_at`)return e.first_seen_at||``;if(n===`last_seen_at`)return e.last_seen_at||e.updated_at||``}return t[n]??``}function Un(e,t,n){let r=n.direction===`asc`?1:-1;return[...t].sort((t,i)=>{let a=Hn(e,t,n.key),o=Hn(e,i,n.key);return typeof a==`number`&&typeof o==`number`?(a-o)*r:String(a).localeCompare(String(o),void 0,{numeric:!0})*r})}function Wn({label:e,value:t,id:n}){return(0,M.jsxs)(G,{className:`p-4`,children:[(0,M.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:e}),(0,M.jsx)(`strong`,{id:n,className:`block text-2xl`,children:t})]})}function Gn({value:e,query:t}){let n=t.trim().toLowerCase();if(!n)return(0,M.jsx)(M.Fragment,{children:e});let r=e.toLowerCase(),i=[],a=0,o=r.indexOf(n);for(;o>=0;){o>a&&i.push(e.slice(a,o));let t=o+n.length;i.push((0,M.jsx)(`mark`,{className:`rounded-sm bg-amber-200 px-0.5 text-inherit dark:bg-amber-500/35`,children:e.slice(o,t)},`${o}-${t}`)),a=t,o=r.indexOf(n,a)}return avoid 0);function Ft(e){return s.includes(e)}function It(e){return s.includes(`${e}:dry_run`)}function Lt(e){return Ft(e)||It(e)}function zt(e){return Ft(On[e])}function Bt(){return Object.keys(Dn).find(e=>zt(e))||`people`}function R(e,t){o({message:e,tone:t})}function z(e,t){R(In(e,t),`error`)}function B(e,t){Fe(n=>({...n,[e]:t}))}function V(e,t=!1){let n=e;zt(n)||(R(`${n[0].toUpperCase()}${n.slice(1)} requires SSO validation`,`error`),n=Bt()),n!==`gigs`&&A(``),n!==`projects`&&N(``),n===`gigs`&&t&&A(``),n===`projects`&&t&&N(``),n===`gigs`&&F(t?`gigs`:Bn()),i(n),t?window.history.pushState({view:n},``,Dn[n]):(!Object.hasOwn(Dn,Ln())||n!==e)&&window.history.replaceState({view:n},``,Dn[n])}Pt.current=V;function Vt(e){F(e),Vn(e)}function H(e){return!u||!e?``:`${u}/#Contact/view/${encodeURIComponent(e)}`}function Ht(e){return!u||!e?``:`${u}/api/v1/Attachment/file/${encodeURIComponent(e)}`}function Ut(e,t){Be(n=>{let r=n[e];return{...n,[e]:{key:t,direction:r.key===t&&r.direction===`asc`?`desc`:`asc`}}})}function Wt(e){A(e),de(h.find(t=>t.id===e)||null),i(`gigs`),window.history.pushState({view:`gigs`,gigId:e},``,`/dashboard/gigs/${encodeURIComponent(e)}`)}function Gt(){A(``),de(null),window.history.replaceState({view:`gigs`},``,Dn.gigs)}function U(e){N(e),i(`projects`),window.history.pushState({view:`projects`,projectId:e},``,`/dashboard/projects/${encodeURIComponent(e)}`)}function Kt(){N(``),window.history.replaceState({view:`projects`},``,Dn.projects)}async function G(){let e=await X(`/dashboard/api/me`);n(e);let t=Array.isArray(e.permissions)?e.permissions:[];return c(t),d((e.crm_base_url||``).replace(/\/+$/,``)),t}function qt(){let e=new URLSearchParams({minutes:Ve,limit:`100`});return Ue&&e.set(`status`,Ue),Ge.trim()&&e.set(`type`,Ge.trim()),`/dashboard/api/jobs?${e.toString()}`}function Jt(e={}){let t=e.status??qe,n=e.query??Ye,r=new URLSearchParams({limit:String($e)});return t&&r.set(`status`,t),n.trim()&&r.set(`query`,n.trim()),Ze&&r.set(`include_historical`,`true`),`/dashboard/api/gigs?${r.toString()}`}function Yt(){let e=new URLSearchParams({limit:`50`});return nt&&e.set(`status`,nt),`/dashboard/api/gig-leads?${e.toString()}`}function K(e={}){let t=new URLSearchParams;e.includeAvailable&&t.set(`include_available`,`true`);let n=t.toString();return`/dashboard/api/job-channels${n?`?${n}`:``}`}function q(){let e=new URLSearchParams({limit:`100`,status:ot});return it.trim()&&e.set(`query`,it.trim()),`/dashboard/api/projects?${e.toString()}`}async function J(){B(`jobs`,!0),R(`Loading background tasks`);try{let e=await X(qt());p(e),R(`Loaded ${e.length} background task${e.length===1?``:`s`}`,`ok`)}catch(e){z(e,`Unable to load background tasks`)}finally{B(`jobs`,!1)}}async function Xt(e={}){B(`gigs`,!0);try{let t=await X(Jt(e));y(t),R(`Loaded ${t.length} gig${t.length===1?``:`s`}`,`ok`),Wn()}catch(e){z(e,`Unable to load gigs`)}finally{B(`gigs`,!1)}}async function Y(){B(`gigLeads`,!0);try{let e=await X(Yt());C(e),R(`Loaded ${e.length} lead${e.length===1?``:`s`}`,`ok`)}catch(e){z(e,`Unable to load job leads`)}finally{B(`gigLeads`,!1)}}async function Zt(e={}){if(Ft(`gigs:read`)){B(`jobPostChannels`,!0);try{let t=await X(K(e));ne(t.channels||[]),e.includeAvailable&&oe(t.available_channels||t.channels||[])}catch(e){z(e,`Unable to load job channels`)}finally{B(`jobPostChannels`,!1)}}}function Qt(e){ne(e.channels||[]),oe(e.available_channels||e.channels||[])}async function en(e,t){B(`jobPostChannel:${e}`,!0);try{return Qt(await X(`/dashboard/api/job-channels/${encodeURIComponent(e)}`,{method:`PUT`,headers:{"Content-Type":`application/json`},body:JSON.stringify({posting_type:t})})),R(`Saved job channel`,`ok`),!0}catch(e){return z(e,`Unable to save job channel`),!1}finally{B(`jobPostChannel:${e}`,!1)}}async function tn(e){B(`jobPostChannel:${e}`,!0);try{Qt(await X(`/dashboard/api/job-channels/${encodeURIComponent(e)}`,{method:`DELETE`})),R(`Deregistered job channel`,`ok`)}catch(e){z(e,`Unable to deregister job channel`)}finally{B(`jobPostChannel:${e}`,!1)}}async function rn(e){for(let t=0;twindow.setTimeout(e,Tn));let t=await X(`/dashboard/api/jobs/${encodeURIComponent(e)}`),n=String(t.status||``).trim().toLowerCase();if($t(n)){if(n===`succeeded`){R(`HN lead scrape finished; refreshing leads`,`ok`),await Y(),r===`jobs`&&await J();return}r===`jobs`&&await J(),R(`HN lead scrape ${n||`finished`}; check background task ${e}`,`warning`);return}}R(`HN lead scrape is still running; refresh leads or check background task ${e}`,`warning`)}async function an(){B(`projects`,!0);try{let e=await X(q());E(e.projects||[]),O(e.summary||{}),R(`Loaded ${(e.projects||[]).length} project${(e.projects||[]).length===1?``:`s`}`,`ok`)}catch(e){z(e,`Unable to load projects`)}finally{B(`projects`,!1)}}async function on(){B(`syncProjects`,!0),R(`Queueing project sync`);try{let e=await X(`/dashboard/api/sync/projects`,{method:`POST`});e.dry_run?R(`Dry run only: would queue ${e.would_enqueue?.job_type||`project sync`}`,`warning`):R(`Queued project sync ${e.job_id}`,`ok`)}catch(e){z(e,`Unable to queue project sync`)}finally{B(`syncProjects`,!1)}}async function sn(e){let t=e.trim();if(t.length<2)return[];try{return(await X(`/dashboard/api/erpnext/customers?${new URLSearchParams({query:t}).toString()}`)).customers||[]}catch(e){return R(e instanceof Error?e.message:`Unable to search customers`,`error`),[]}}async function cn(e){let t=e.trim();if(t.length<2)return[];try{return(await X(`/dashboard/api/erpnext/contacts?${new URLSearchParams({query:t}).toString()}`)).contacts||[]}catch(e){return R(e instanceof Error?e.message:`Unable to search contacts`,`error`),[]}}async function ln(e){let t=e.trim();if(t.length<2)return[];try{return(await X(`/dashboard/api/erpnext/account-managers?${new URLSearchParams({query:t}).toString()}`)).users||[]}catch(e){return R(e instanceof Error?e.message:`Unable to search account managers`,`error`),[]}}async function un(){try{let e=(await X(`/dashboard/api/erpnext/cost-centers`)).cost_centers||[];return e.length?e:[{name:`Projects - 5`,cost_center_name:`Projects`}]}catch(e){return R(e instanceof Error?e.message:`Unable to load cost centers`,`error`),[{name:`Projects - 5`,cost_center_name:`Projects`}]}}async function dn(e){B(`createProject`,!0);try{let t=await X(`/dashboard/api/projects/create`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify(e)});return t.project.id?(E(e=>e.some(e=>e.id===t.project.id)?e.map(e=>e.id===t.project.id?t.project:e):[t.project,...e]),R(t.setup_warnings?.length?t.setup_warning_message||`Created ERP project setup; account manager setup needs follow-up`:`Created ERP project setup`,t.setup_warnings?.length?`warning`:`ok`),U(t.project.id)):(R([t.cache_refresh_message||`Created ERP project in ERPNext; local sync is pending`,t.setup_warnings?.length?t.setup_warning_message||`Account manager setup needs follow-up`:``].filter(Boolean).join(` `),t.setup_warnings?.length?`warning`:`ok`),an()),!0}catch(e){return R(e instanceof Error?e.message:`Unable to create project`,`error`),!1}finally{B(`createProject`,!1)}}async function fn(e,t){B(`project:${e}:status`,!0);try{let n=await X(`/dashboard/api/projects/${encodeURIComponent(e)}/status`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:t})});E(t=>t.map(t=>t.id===e?n.project:t)),R(`Updated project status`,`ok`)}catch(e){z(e,`Unable to update project`)}finally{B(`project:${e}:status`,!1)}}async function pn(e,t){if(e.length===0)return!1;B(`projectsBulkUpdate`,!0);try{let n=await X(`/dashboard/api/projects/bulk`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({project_ids:e,...t})}),r=n.projects||[];E(e=>e.map(e=>r.find(t=>t.id===e.id)||e));let i=n.failures||[];return R(i.length?`Updated ${r.length}; ${i.length} failed`:`Updated ${r.length} project${r.length===1?``:`s`}`,i.length?`error`:`ok`),i.length===0}catch(e){return z(e,`Unable to bulk update projects`),!1}finally{B(`projectsBulkUpdate`,!1)}}async function mn(e,t,n,r){let i=t.trim(),a=n.trim();if(!i||!a)return!1;B(`project:${e}:user`,!0);try{let t=await X(`/dashboard/api/projects/${encodeURIComponent(e)}/users`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({user:i,candidate_id:a,...r||{}})});return E(n=>n.map(n=>n.id===e?t.project:n)),R(t.activity_cost_error?`Added project user; rate failed`:t.activity_cost?`Added project user and rate`:`Added project user`,t.activity_cost_error?`error`:`ok`),!0}catch(e){return z(e,`Unable to add project user`),!1}finally{B(`project:${e}:user`,!1)}}async function hn(e,t){let n=t.trim();if(!n)return!1;B(`project:${e}:user`,!0);try{let t=await X(`/dashboard/api/projects/${encodeURIComponent(e)}/users/remove`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({user:n})});return E(n=>n.map(n=>n.id===e?t.project:n)),R(`Removed project user`,`ok`),!0}catch(e){return R(e instanceof Error?e.message:`Unable to remove project user`,`error`),!1}finally{B(`project:${e}:user`,!1)}}async function gn(e,t,n){let r=t.trim();if(!r)return!1;B(`project:${e}:historical`,!0);try{let t=await X(`/dashboard/api/projects/${encodeURIComponent(e)}/historical-members`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({person:r,candidate_id:n})});return E(n=>n.map(n=>n.id===e?t.project:n)),ze(null),R(`Added historical project member`,`ok`),!0}catch(t){if(t instanceof Pn&&t.status===409){let n=t.payload?.candidates||[];if(n.length>0)return ze({projectId:e,person:r,candidates:n}),R(`Choose the matching person record`,`error`),!1}return z(t,`Unable to add historical member`),!1}finally{B(`project:${e}:historical`,!1)}}async function _n(e,t){let n=t.trim();if(!n)return!1;B(`project:${e}:historical`,!0);try{let t=await X(`/dashboard/api/projects/${encodeURIComponent(e)}/historical-members/remove`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({source_user_id:n})});return E(n=>n.map(n=>n.id===e?t.project:n)),R(`Removed historical project member`,`ok`),!0}catch(e){return R(e instanceof Error?e.message:`Unable to remove historical member`,`error`),!1}finally{B(`project:${e}:historical`,!1)}}async function vn(e,t,n){B(`project:${e}:wiki`,!0);try{await X(`/dashboard/api/projects/${encodeURIComponent(e)}/wiki-match`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:t,row_key:n})}),R(t===`no_row`?`Marked as no wiki row`:`Confirmed wiki match`,`ok`),await yn()}catch(e){z(e,`Unable to save wiki match`)}finally{B(`project:${e}:wiki`,!1)}}async function yn(){B(`wikiMatches`,!0);try{le(await X(`/dashboard/api/projects/wiki-matches`)),R(`Loaded wiki match preview`,`ok`)}catch(e){z(e,`Unable to load wiki matches`)}finally{B(`wikiMatches`,!1)}}async function Sn(e){B(`gig:${e}:detail`,!0);try{de(await X(`/dashboard/api/gigs/${encodeURIComponent(e)}`))}catch(e){de(null),z(e,`Unable to load gig`)}finally{B(`gig:${e}:detail`,!1)}}async function An(){await Xt(),await Y(),await Zt(),k&&await Sn(k)}async function jn(){B(`gigLeadsSync`,!0),R(`Queueing HN lead scrape`);try{let e=await X(`/dashboard/api/gig-leads/sync`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({source:`hackernews_who_is_hiring`})});if(R(e.created===!1?`HN lead scrape already queued`:`HN lead scrape queued`,`ok`),e.job_id)try{await rn(e.job_id)}catch(e){z(e,`Unable to monitor HN lead scrape`)}else await Y()}catch(e){z(e,`Unable to queue HN lead scrape`)}finally{B(`gigLeadsSync`,!1)}}async function Fn(e,t){B(`gigLead:${e}:review`,!0);try{await X(`/dashboard/api/gig-leads/${encodeURIComponent(e)}/review`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:t})}),R(`${t===`approved`?`Approved`:`Rejected`} lead`,`ok`),await Y()}catch(e){z(e,`Unable to review lead`)}finally{B(`gigLead:${e}:review`,!1)}}async function Hn(e,t={}){B(`gigLead:${e}:post`,!0);try{await X(`/dashboard/api/gig-leads/${encodeURIComponent(e)}/post`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({channel_id:t.channelId||void 0,engagement_status:t.engagementStatus||`lead`,tags:(t.tags||[]).join(`,`)||void 0})}),R(`Posted lead to Discord`,`ok`),Vt(`gigs`),Je(``),Xe(``),await Y(),await Xt({status:``,query:``})}catch(e){z(e,`Unable to post lead`)}finally{B(`gigLead:${e}:post`,!1)}}async function Wn(){if(Ft(`gigs:read`)){B(`notifications`,!0);try{let e=await X(`/dashboard/api/notifications?limit=20`);lt(e.stale_days||7),dt(e.contacted_reminder_days||5),pe(e.notifications||[])}catch(e){z(e,`Unable to load notifications`)}finally{B(`notifications`,!1)}}}async function Gn(e,t){B(`gig:${e}:status`,!0);try{let n=(await X(`/dashboard/api/gigs/${encodeURIComponent(e)}/status`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:t})})).discord_title_sync?.status;R(n===`error`?`Updated gig status; Discord title sync failed`:`Updated gig status`,n===`error`?`error`:`ok`),await Xt(),k===e&&await Sn(e)}catch(e){z(e,`Unable to update gig`)}finally{B(`gig:${e}:status`,!1)}}async function Kn(e,t,n){B(`application:${t}:status`,!0);try{await X(`/dashboard/api/gigs/${encodeURIComponent(e)}/applications/${encodeURIComponent(t)}/status`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:n})}),R(`Updated candidate status`,`ok`),await Xt(),k===e&&await Sn(e)}catch(e){z(e,`Unable to update candidate`)}finally{B(`application:${t}:status`,!1)}}async function Xn(e,t){let n=t.trim();if(!n)return R(`Choose a candidate first`,`warning`),!1;B(`gig:${e}:addCandidate`,!0);try{return await X(`/dashboard/api/gigs/${encodeURIComponent(e)}/applications`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({crm_profile:n})}),R(`Added candidate`,`ok`),await Xt(),k===e&&await Sn(e),!0}catch(e){return z(e,`Unable to add candidate`),!1}finally{B(`gig:${e}:addCandidate`,!1)}}function Zn(){let e=new URLSearchParams({limit:`25`});ft.trim()&&e.set(`query`,ft.trim()),pt&&e.set(`is_member`,pt);for(let[t,n]of Object.entries(ht))n&&e.set(t,n);return`/dashboard/api/people?${e.toString()}`}async function Qn(){B(`people`,!0);try{_e(await X(Zn()))}catch(e){z(e,`Unable to load people`)}finally{B(`people`,!1)}}async function $n(){B(`newsletterStatus`,!0);try{xe(await X(`/dashboard/api/newsletter/status`))}catch(e){z(e,`Unable to load newsletter sync status`)}finally{B(`newsletterStatus`,!1)}}async function er(){B(`newsletterSuppressions`,!0);try{ye((await X(`/dashboard/api/newsletter/suppressions?limit=200`)).suppressions||[])}catch(e){z(e,`Unable to load newsletter suppressions`)}finally{B(`newsletterSuppressions`,!1)}}async function tr(){await Promise.all([$n(),er()])}function nr(){let e=new URLSearchParams({limit:`25`});St.trim()&&e.set(`query`,St.trim()),wt&&e.set(`onboarding_state`,wt),Et.trim()&&e.set(`onboarder`,Et.trim());for(let[t,n]of Object.entries(Ot))n&&e.set(t,n);return`/dashboard/api/onboarding?${e.toString()}`}async function rr(){B(`onboarding`,!0);try{Ce(await X(nr()))}catch(e){z(e,`Unable to load onboarding`)}finally{B(`onboarding`,!1)}}async function ir(e,t){if(!e)return R(`Missing CRM contact`,`error`),null;let n=`onboarding-email-draft:${e}`;B(n,!0);try{let n=await X(`/dashboard/api/onboarding/${encodeURIComponent(e)}/email/draft`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify(t)});return R(`Drafted onboarding email`,`ok`),n}catch(e){return z(e,`Unable to draft onboarding email`),null}finally{B(n,!1)}}async function ar(e,t,n){if(!e)return R(`Missing CRM contact`,`error`),null;let r=`onboarding-email-send:${e}`;B(r,!0);try{let r=await X(`/dashboard/api/onboarding/${encodeURIComponent(e)}/email/send`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({...t,markdown_body:n})});return Ce(t=>t.map(t=>t.crm_contact_id===e?{...t,onboarding_email_sent_at:r.onboarding_email_sent_at||t.onboarding_email_sent_at,onboarding_email_sent_by:r.onboarding_email_sent_by||t.onboarding_email_sent_by,onboarding_email_recipient:r.onboarding_email_recipient||r.recipient_email||t.onboarding_email_recipient}:t)),R(`Sent onboarding email`,`ok`),r}catch(e){return z(e,`Unable to send onboarding email`),null}finally{B(r,!1)}}async function sr(){B(`audit`,!0);try{Te(await X(`/dashboard/api/audit-events?limit=25`))}catch(e){z(e,`Unable to load audit events`)}finally{B(`audit`,!1)}}async function cr(){B(`agent`,!0);try{De(await X(`/dashboard/api/agent?limit=100`))}catch(e){z(e,`Unable to load agent report`)}finally{B(`agent`,!1)}}async function ur(){B(`configuration`,!0);try{ke((await X(`/dashboard/api/configuration`)).items)}catch(e){z(e,`Unable to load configuration`)}finally{B(`configuration`,!1)}}async function dr(e,t){B(`configuration:${e}`,!0);try{return ke((await X(`/dashboard/api/configuration/${encodeURIComponent(e)}`,{method:`PUT`,headers:{"Content-Type":`application/json`},body:JSON.stringify({value:t})})).items),R(`Saved ${e}`,`ok`),!0}catch(t){return z(t,`Unable to save ${e}`),!1}finally{B(`configuration:${e}`,!1)}}async function fr(e){B(`configuration:${e}`,!0);try{ke((await X(`/dashboard/api/configuration/${encodeURIComponent(e)}`,{method:`PUT`,headers:{"Content-Type":`application/json`},body:JSON.stringify({clear:!0})})).items),R(`Cleared ${e}`,`ok`)}catch(t){z(t,`Unable to clear ${e}`)}finally{B(`configuration:${e}`,!1)}}async function pr(e){B(`detail:${e}`,!0),R(`Loading ${e}`);try{Ne(await X(`/dashboard/api/jobs/${encodeURIComponent(e)}`)),R(`Loaded ${e}`,`ok`)}catch(e){z(e,`Unable to load task detail`)}finally{B(`detail:${e}`,!1)}}async function mr(e){B(`rerun:${e}`,!0),R(`Rerunning ${e}`);try{let t=await X(`/dashboard/api/jobs/${encodeURIComponent(e)}/rerun`,{method:`POST`});t.dry_run?R(`Dry run only: would rerun ${t.would_enqueue?.job_type||e}`,`warning`):(R(`Queued rerun ${t.job_id}`,`ok`),await J())}catch(e){z(e,`Unable to rerun task`)}finally{B(`rerun:${e}`,!1)}}async function hr(){B(`syncPeople`,!0),R(`Queueing people sync`);try{let e=await X(`/dashboard/api/sync/people`,{method:`POST`});e.dry_run?R(`Dry run only: would queue ${e.would_enqueue?.job_type||`people sync`}`,`warning`):R(`Queued people sync ${e.job_id}`,`ok`)}catch(e){z(e,`Unable to queue people sync`)}finally{B(`syncPeople`,!1)}}async function gr(){B(`syncNewsletters`,!0),R(`Queueing newsletter sync`);try{let e=await X(`/dashboard/api/sync/newsletters`,{method:`POST`});if(e.dry_run){let t=xn(e.preview);R(t?`Dry run only: ${t}`:`Dry run completed`,`warning`)}else R(`Queued newsletter sync ${e.job_id}`,`ok`);tr()}catch(e){z(e,`Unable to queue newsletter sync`)}finally{B(`syncNewsletters`,!1)}}async function _r(e,t){let n=String(e||``).trim(),r=t.trim();if(!n){R(`Missing CRM contact id`,`error`);return}if(!r){R(`Enter a 508 username`,`error`);return}B(`onboarder:${n}`,!0),R(`Assigning ${r}`);try{let e=await X(`/dashboard/api/onboarding/${encodeURIComponent(n)}/onboarder`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({onboarder:r})});Ce(t=>t.map(t=>t.crm_contact_id===e.contact_id?{...t,onboarder:e.onboarder,onboarding_state:e.state_updated&&e.onboarding_state?e.onboarding_state:t.onboarding_state,onboarding_status_label:e.onboarding_status_label||(e.state_updated?void 0:t.onboarding_status_label)}:t)),R(`Assigned ${e.onboarder}`,`ok`)}catch(e){z(e,`Unable to assign onboarder`)}finally{B(`onboarder:${n}`,!1)}}async function vr(e,t){let n=String(e||``).trim(),r=t.trim();if(!n){R(`Missing CRM contact id`,`error`);return}if(!r){R(`Choose an onboarding status`,`error`);return}B(`onboarding-status:${n}`,!0),R(`Updating onboarding status`);try{let e=await X(`/dashboard/api/onboarding/${encodeURIComponent(n)}/status`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:r})}),t=Nn(e.onboarding_state),i=e.onboarding_status_label||nn(t);Ce(n=>n.map(n=>n.crm_contact_id===e.contact_id?{...n,onboarding_state:t,onboarding_status_label:i}:n).filter(n=>n.crm_contact_id!==e.contact_id||!Mn.has(t))),R(`Status set to ${i}`,`ok`)}catch(e){z(e,`Unable to update onboarding status`)}finally{B(`onboarding-status:${n}`,!1)}}async function yr(e){let t=e.email.trim().toLowerCase(),n=e.first_name.trim();if(!t?.endsWith(`@508.dev`))return R(`Enter the engineer's @508.dev email`,`error`),null;if(!n)return R(`Enter the engineer name`,`error`),null;B(`engineerSetup`,!0);try{let r=await X(`/dashboard/api/onboarding/engineers`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({...e,email:t,first_name:n})});return R(`Set up ${r.employee_name||r.user||t}`,`ok`),r}catch(e){if(e instanceof Pn&&e.status===409){let t=e.payload&&typeof e.payload==`object`?e.payload:null,n=(Array.isArray(t?.matches)?t.matches:[]).map(e=>e?.label||e?.email).filter(Boolean).slice(0,2).join(`, `);R(n?`Similar account exists: ${n}`:`Similar account exists; confirm before creating`,`error`)}else R(e instanceof Error?e.message:`Unable to set up engineer`,`error`);return null}finally{B(`engineerSetup`,!1)}}async function br(){B(`logout`,!0);try{let e=await X(`/auth/logout`,{method:`POST`});window.location.assign(e.end_session_url||`/dashboard`)}catch(e){z(e,`Unable to log out`),B(`logout`,!1)}}(0,l.useEffect)(()=>{G().then(e=>{let t=Rn(),n=e.includes(On[t])?t:Object.keys(Dn).find(t=>e.includes(On[t]))||`people`;A(n===`gigs`?zn():``),N(n===`projects`?zn(`projects`):``),i(n),(!Object.hasOwn(Dn,Ln())||n!==t)&&window.history.replaceState({view:n},``,Dn[n])}).catch(e=>{z(e,`Dashboard failed to load`)})},[]),(0,l.useEffect)(()=>{let e=()=>{A(zn()),N(zn(`projects`)),Pt.current(Rn(),!1)};return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)},[]),(0,l.useEffect)(()=>{let e=()=>F(Bn());return window.addEventListener(`hashchange`,e),()=>window.removeEventListener(`hashchange`,e)},[]),(0,l.useEffect)(()=>{if(!a.message)return;let e=window.setTimeout(()=>o({message:``}),4500);return()=>window.clearTimeout(e)},[a.message]),(0,l.useEffect)(()=>{},[]),(0,l.useEffect)(()=>{s.length!==0&&(Ft(`gigs:read`)&&Wn(),r===`people`&&Qn(),r===`gigs`&&(Xt(),Y(),Zt()),r===`projects`&&an(),r===`onboarding`&&rr(),r===`newsletter`&&tr(),r===`jobs`&&J(),r===`agent`&&cr(),r===`audit`&&sr(),r===`configuration`&&(ur(),Zt({includeAvailable:!0})))},[r]),(0,l.useEffect)(()=>{s.length!==0&&(Ft(`gigs:read`)&&Wn(),r===`people`&&Qn(),r===`gigs`&&(Xt(),Y(),Zt()),r===`projects`&&an(),r===`onboarding`&&rr(),r===`newsletter`&&tr(),r===`jobs`&&J(),r===`agent`&&cr(),r===`audit`&&sr(),r===`configuration`&&(ur(),Zt({includeAvailable:!0})))},[s]),(0,l.useEffect)(()=>{r===`jobs`&&s.length>0&&J()},[Ve,Ue]),(0,l.useEffect)(()=>{r===`gigs`&&s.length>0&&Xt()},[qe,Ze,$e]),(0,l.useEffect)(()=>{r===`gigs`&&s.length>0&&Y()},[nt]),(0,l.useEffect)(()=>{r===`projects`&&s.length>0&&an()},[ot]),(0,l.useEffect)(()=>{r===`gigs`&&k&&s.length>0&&Sn(k)},[r,k,s]),(0,l.useEffect)(()=>{r===`people`&&s.length>0&&Qn()},[pt]),(0,l.useEffect)(()=>{r===`people`&&s.length>0&&Qn()},[ht]),(0,l.useEffect)(()=>{r===`onboarding`&&s.length>0&&rr()},[wt]),(0,l.useEffect)(()=>{r===`onboarding`&&s.length>0&&rr()},[Ot]);let xr=(0,l.useMemo)(()=>Un(`jobs`,f,P.jobs),[f,P.jobs]),Sr=(0,l.useMemo)(()=>Un(`people`,ge,P.people),[ge,P.people]),Cr=(0,l.useMemo)(()=>Un(`onboarding`,Se,P.onboarding),[Se,P.onboarding]),Er=(0,l.useMemo)(()=>Un(`gigs`,h,P.gigs),[h,P.gigs]),Dr=(0,l.useMemo)(()=>Un(`projects`,se,P.projects),[se,P.projects]),Or=(0,l.useMemo)(()=>{let e=new Set;for(let t of ve)t.source_provider&&e.add(t.source_provider);for(let[t]of Cn(be?.latest_job))e.add(t);return[...e].sort((e,t)=>e.localeCompare(t))},[ve,be]),kr=(0,l.useMemo)(()=>Un(`newsletter`,bt?ve.filter(e=>e.source_provider===bt):ve,P.newsletter),[bt,ve,P.newsletter]),Ar=(0,l.useMemo)(()=>ue?.id===k?ue:Er.find(e=>e.id===k)||null,[ue,k,Er]),jr=(0,l.useMemo)(()=>Dr.find(e=>e.id===j)||null,[j,Dr]),Mr=(0,l.useMemo)(()=>Un(`audit`,we,P.audit),[we,P.audit]),Nr=(0,l.useMemo)(()=>f.reduce((e,t)=>(e[t.status]=(e[t.status]||0)+1,e),{}),[f]),Pr=Object.keys(kn).filter(e=>!ht[e]),Fr=Object.keys(kn).filter(e=>e!==`sync_status`&&e!==`email_508`&&!Ot[e]);function Ir(e){if(e.type===`stale_recruiting_gig`||e.type===`contacted_gig`){let t=e.type===`contacted_gig`,n=t?`contacted-gig:`:`stale-recruiting:`,r=e.engagement_id||(e.id.startsWith(n)?e.id.slice(n.length):``);r?Wt(r):(Je(t?`contacted`:`recruiting`),V(`gigs`,!0))}he(!1)}(0,l.useEffect)(()=>{!Pr.includes(_t)&&Pr[0]&&vt(Pr[0])},[Pr,_t]),(0,l.useEffect)(()=>{let e=kn[_t]?.options;e?.[0]&&!e.some(([e])=>e===yt)&&L(e[0][0])},[_t,yt]),(0,l.useEffect)(()=>{!Fr.includes(At)&&Fr[0]&&jt(Fr[0])},[Fr,At]),(0,l.useEffect)(()=>{let e=kn[At]?.options;e?.[0]&&!e.some(([e])=>e===Mt)&&Nt(e[0][0])},[At,Mt]);let Br=[t?.email,t?.crm_contact_id?`CRM ${t.crm_contact_id}`:``,t?.actor_provider].filter(Boolean).join(` | `);return(0,M.jsxs)(M.Fragment,{children:[(0,M.jsx)(`header`,{className:`sticky top-0 z-20 border-b bg-background/90 backdrop-blur`,children:(0,M.jsxs)(`div`,{className:`mx-auto flex max-w-7xl flex-col gap-4 px-5 py-4 md:flex-row md:items-center md:justify-between`,children:[(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`h1`,{className:`text-xl font-bold`,children:`508 Operations Dashboard`}),(0,M.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Operations view for authenticated 508 operators.`})]}),(0,M.jsxs)(`div`,{className:`flex min-w-0 items-center gap-3`,children:[Ft(`gigs:read`)?(0,M.jsx)(`div`,{className:`relative`,children:(0,M.jsxs)(W,{id:`notifications`,type:`button`,variant:`outline`,size:`icon`,"aria-label":`Notifications`,"aria-expanded":me,onClick:()=>he(e=>!e),children:[(0,M.jsx)(g,{}),fe.length>0?(0,M.jsx)(`span`,{className:`absolute -right-1 -top-1 grid min-h-5 min-w-5 place-items-center rounded-full bg-red-500 px-1 text-[11px] font-bold text-white`,children:fe.length}):null]})}):null,(0,M.jsxs)(`div`,{className:`grid min-w-0 gap-0.5 text-right text-sm text-muted-foreground`,children:[(0,M.jsx)(`strong`,{id:`userName`,className:`truncate text-foreground`,children:t?.display_name||t?.email||t?.subject||`Loading user`}),(0,M.jsx)(`span`,{id:`userMeta`,className:`truncate`,children:Br||`Checking session`})]}),(0,M.jsxs)(W,{id:`logout`,type:`button`,variant:`outline`,onClick:br,disabled:Pe.logout,children:[(0,M.jsx)(ee,{}),`Log out`]})]})]})}),(0,M.jsx)(qn,{open:me,notifications:fe,loading:Pe.notifications,onClose:()=>he(!1),onRefresh:Wn,onOpenNotification:Ir}),(0,M.jsx)(Yn,{toast:a}),null,(0,M.jsx)(Jn,{choice:Re,loading:!!(Re&&Pe[`project:${Re.projectId}:historical`]),crmContactUrl:H,onClose:()=>ze(null),onChoose:e=>{Re&&gn(Re.projectId,Re.person,e)}}),(0,M.jsxs)(`main`,{className:`mx-auto grid max-w-7xl grid-cols-1 gap-5 px-5 py-5 md:grid-cols-[190px_minmax(0,1fr)]`,children:[(0,M.jsx)(`nav`,{className:`grid content-start gap-1 md:sticky md:top-24`,"aria-label":`Dashboard sections`,children:[[`people`,`People`,T],[`gigs`,`Gigs`,_],[`projects`,`Projects`,x],[`onboarding`,`Onboarding`,v],[`newsletter`,`Newsletter`,te],[`jobs`,`Background tasks`,m],[`agent`,`Agent`,ie],[`audit`,`Audit`,b],[`configuration`,`Configuration`,re]].filter(([e])=>zt(e)).map(([e,t,n])=>(0,M.jsxs)(`a`,{className:Rt(`flex min-h-10 items-center gap-2 rounded-md border border-transparent px-3 text-sm font-extrabold text-muted-foreground hover:border-border hover:bg-secondary hover:text-foreground`,r===e&&`border-primary bg-accent text-accent-foreground`),"data-view-link":e,"data-permission":On[e],href:Dn[e],"aria-current":r===e?`page`:void 0,onClick:t=>{t.preventDefault(),V(e,!0)},children:[(0,M.jsx)(n,{className:`size-4`}),t]},e))}),(0,M.jsxs)(`div`,{className:`grid min-w-0 gap-5`,children:[r===`people`?(0,M.jsx)(wr,{crmBaseUrl:u,people:Sr,sort:P.people,canSync:Lt(`people:sync`),loading:Pe,peopleQuery:ft,peopleMember:pt,peopleFilters:ht,peopleFilterKind:_t,peopleFilterValue:yt,peopleFilterKeys:Pr,onSearch:Qn,onSync:hr,onSort:e=>Ut(`people`,e),setPeopleQuery:I,setPeopleMember:mt,setPeopleFilterKind:vt,setPeopleFilterValue:L,addFilter:()=>{gt(e=>({...e,[_t]:yt}))},removeFilter:e=>{gt(t=>{let n={...t};return delete n[e],n})},crmContactUrl:H,crmAttachmentUrl:Ht}):null,r===`newsletter`?(0,M.jsx)(wn,{status:be,suppressions:kr,providerOptions:Or,providerFilter:bt,sort:P.newsletter,loading:Pe,canSync:Lt(`people:sync`),onRefresh:tr,onSync:gr,onProviderFilterChange:xt,onSort:e=>Ut(`newsletter`,e)}):null,r===`gigs`?(0,M.jsx)(lr,{gigs:Er,leads:S,jobPostChannels:w,selectedGig:Ar,selectedGigId:k,sort:P.gigs,loading:Pe,activeTab:tt,status:qe,query:Ye,leadStatus:nt,includeHistorical:Ze,limit:$e,staleDays:ct,contactedReminderDays:ut,canWrite:Ft(`gigs:write`),canSearchCandidates:Ft(`people:read`),canManageLeads:Ft(`people:read`),canIncludeHistorical:Ft(`people:read`),crmContactUrl:H,crmAttachmentUrl:Ht,setActiveTab:Vt,setStatus:Je,setQuery:Xe,setLeadStatus:rt,setIncludeHistorical:Qe,setLimit:et,onRefresh:An,onSort:e=>Ut(`gigs`,e),onOpenGig:Wt,onCloseGig:Gt,onUpdateStatus:Gn,onAddApplication:Xn,onUpdateApplicationStatus:Kn,onSyncLeads:jn,onReviewLead:Fn,onPostLead:Hn}):null,r===`projects`?(0,M.jsx)(or,{projects:Dr,selectedProject:jr,selectedProjectId:j,summary:D,wikiMatches:ce,sort:P.projects,loading:Pe,query:it,status:ot,canSync:Lt(`projects:sync`),canWrite:Ft(`projects:write`),crmContactUrl:H,setQuery:at,setStatus:st,onSearch:an,onSync:on,onSearchCustomers:sn,onSearchContacts:cn,onSearchAccountManagers:ln,onLoadCostCenters:un,onCreateProject:dn,onUpdateStatus:fn,onBulkUpdate:pn,onAddUser:mn,onRemoveUser:hn,onAddHistoricalMember:gn,onRemoveHistoricalMember:_n,onUpdateWikiMatch:vn,onWikiMatches:yn,onOpenProject:U,onCloseProject:Kt,onSort:e=>Ut(`projects`,e)}):null,r===`onboarding`?(0,M.jsx)(Tr,{people:Cr,sort:P.onboarding,loading:Pe,onboardingQuery:St,onboardingState:wt,onboarderFilter:Et,onboardingFilters:Ot,onboardingFilterKind:At,onboardingFilterValue:Mt,onboardingFilterKeys:Fr,onSearch:rr,onSort:e=>Ut(`onboarding`,e),onAssign:_r,onStatusChange:vr,onDraftEmail:ir,onSendEmail:ar,onSetupEngineer:yr,setOnboardingQuery:Ct,setOnboardingState:Tt,setOnboarderFilter:Dt,setOnboardingFilterKind:jt,setOnboardingFilterValue:Nt,addFilter:()=>{kt(e=>({...e,[At]:Mt}))},removeFilter:e=>{kt(t=>{let n={...t};return delete n[e],n})},crmContactUrl:H,crmAttachmentUrl:Ht,canWrite:Ft(`onboarding:write`),canConfigure:Ft(`configuration:write`),onOpenConfiguration:()=>{je({category:`Onboarding`,nonce:Date.now()}),V(`configuration`,!0)}}):null,r===`jobs`?(0,M.jsx)(Lr,{jobs:xr,jobDetail:Me,sort:P.jobs,loading:Pe,minutes:Ve,status:Ue,jobType:Ge,jobCounts:Nr,canWrite:Lt(`jobs:write`),setMinutes:He,setStatus:We,setJobType:Ke,onSearch:J,onSort:e=>Ut(`jobs`,e),onDetail:pr,onRerun:mr}):null,r===`audit`?(0,M.jsx)(Rr,{events:Mr,sort:P.audit,loading:Pe,onRefresh:sr,onSort:e=>Ut(`audit`,e)}):null,r===`agent`?(0,M.jsx)(zr,{report:Ee,loading:Pe,onRefresh:cr}):null,r===`configuration`?(0,M.jsx)(bn,{items:Oe,loading:Pe,canWrite:Ft(`configuration:write`),jobChannels:w,availableJobChannels:ae,focusCategory:Ae?.category,focusNonce:Ae?.nonce,onRefresh:ur,onRefreshJobChannels:()=>Zt({includeAvailable:!0}),onSave:dr,onClear:fr,onSaveJobChannel:en,onDeleteJobChannel:tn}):null]})]})]})}function qn({open:e,notifications:t,loading:n,onClose:r,onRefresh:i,onOpenNotification:a}){return e?(0,M.jsxs)(`div`,{className:`fixed inset-0 z-40`,"aria-labelledby":`notificationsTitle`,"aria-modal":`true`,role:`dialog`,children:[(0,M.jsx)(`button`,{type:`button`,className:`absolute inset-0 cursor-default bg-black/45`,"aria-label":`Close notifications`,onClick:r}),(0,M.jsxs)(`aside`,{className:`absolute right-0 top-0 grid h-full w-full max-w-md grid-rows-[auto_minmax(0,1fr)] border-l bg-background shadow-2xl`,children:[(0,M.jsxs)(`div`,{className:`flex items-center justify-between gap-3 border-b p-4`,children:[(0,M.jsxs)(`div`,{className:`grid gap-0.5`,children:[(0,M.jsx)(`strong`,{id:`notificationsTitle`,className:`text-base`,children:`Notifications`}),(0,M.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:t.length===0?`No active notifications`:`${t.length} active`})]}),(0,M.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,M.jsxs)(W,{type:`button`,variant:`outline`,size:`sm`,onClick:i,disabled:n,children:[(0,M.jsx)(C,{}),`Refresh`]}),(0,M.jsx)(W,{type:`button`,variant:`ghost`,size:`icon`,"aria-label":`Close`,onClick:r,children:(0,M.jsx)(E,{})})]})]}),(0,M.jsx)(`div`,{className:`min-h-0 overflow-auto p-4`,children:t.length===0?(0,M.jsx)(`div`,{className:`rounded-md border border-dashed p-6 text-sm text-muted-foreground`,children:`No active notifications.`}):(0,M.jsx)(`div`,{className:`grid gap-3`,children:t.map(e=>(0,M.jsxs)(`button`,{type:`button`,className:`grid gap-2 rounded-md border p-3 text-left hover:bg-secondary`,onClick:()=>a(e),children:[(0,M.jsx)(`span`,{className:`text-sm font-bold`,children:e.title}),(0,M.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:e.message})]},e.id))})})]})]}):null}function Jn({choice:e,loading:t,crmContactUrl:n,onClose:r,onChoose:i}){return e?(0,M.jsxs)(`div`,{className:`fixed inset-0 z-50 grid place-items-center p-4`,"aria-labelledby":`historicalPersonChoiceTitle`,"aria-modal":`true`,role:`dialog`,children:[(0,M.jsx)(`button`,{type:`button`,className:`absolute inset-0 cursor-default bg-black/45`,"aria-label":`Close person selection`,onClick:r}),(0,M.jsxs)(`div`,{className:`relative grid w-full max-w-2xl gap-4 rounded-md border bg-background p-5 shadow-2xl`,children:[(0,M.jsxs)(`div`,{className:`flex items-start justify-between gap-3`,children:[(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`strong`,{id:`historicalPersonChoiceTitle`,className:`block text-base`,children:`Choose person record`}),(0,M.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:e.person})]}),(0,M.jsx)(W,{type:`button`,variant:`ghost`,size:`icon`,"aria-label":`Close person selection`,onClick:r,children:(0,M.jsx)(E,{})})]}),(0,M.jsx)(`div`,{className:`grid gap-2`,children:e.candidates.map(e=>(0,M.jsxs)(`div`,{className:`grid gap-3 rounded-md border p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center`,children:[(0,M.jsxs)(`div`,{className:`min-w-0`,children:[(0,M.jsx)(`strong`,{className:`block truncate`,children:e.label||e.full_name||e.email||`Person`}),(0,M.jsxs)(`div`,{className:`flex flex-wrap gap-x-3 gap-y-1 text-sm text-muted-foreground`,children:[e.email?(0,M.jsx)(`span`,{children:e.email}):null,e.sources?.length?(0,M.jsx)(`span`,{children:e.sources.join(`, `)}):null,e.erpnext_user_id?(0,M.jsxs)(`span`,{children:[`ERP `,e.erpnext_user_id]}):null,e.supplier_erpnext_id?(0,M.jsxs)(`span`,{children:[`Supplier `,e.supplier_erpnext_id]}):null,e.crm_contact_id&&n(e.crm_contact_id)?(0,M.jsx)(`a`,{className:`font-semibold text-primary underline-offset-4 hover:underline`,href:n(e.crm_contact_id),target:`_blank`,rel:`noreferrer`,children:`CRM`}):null]})]}),(0,M.jsx)(W,{type:`button`,disabled:t,onClick:()=>i(e.candidate_id),children:`Select`})]},e.candidate_id))})]})]}):null}function Yn({toast:e}){return e.message?(0,M.jsx)(`div`,{id:`toast`,role:`status`,className:Rt(`fixed bottom-5 right-5 z-50 max-w-sm rounded-md border bg-background px-4 py-3 text-sm font-semibold shadow-lg`,e.tone===`ok`&&`border-emerald-500/40 text-emerald-300`,e.tone===`warning`&&`border-amber-500/40 text-amber-200`,e.tone===`error`&&`border-red-500/40 text-red-300`),children:e.message}):null}function Xn({filters:e,onRemove:t,suffix:n=`filter`}){return(0,M.jsx)(`fieldset`,{className:`m-0 flex min-h-7 flex-wrap gap-2 border-0 p-0`,"aria-label":`Active filters`,children:Object.entries(e).map(([e,r])=>{let i=kn[e],a=i.options.find(([e])=>e===r),o=`${i.label}: ${a?a[1]:r}`;return(0,M.jsxs)(W,{type:`button`,variant:`outline`,size:`sm`,className:`rounded-full`,"aria-label":`Remove ${o} ${n}`,onClick:()=>t(e),children:[o,` x`]},e)})})}var Zn=[`lead`,`recruiting`,`contacted`,`filled`,`unknown`,`lost`,`duplicate`,`outdated`],Qn=[`suggested`,`interested`,`reviewing`,`contacted`,`accepted`,`unavailable`,`rejected`,`withdrawn`];function $n(e){return String(e||``).replace(/[-_]+/g,` `).replace(/\s+/g,` `).trim().replace(/\b\w/g,e=>e.toUpperCase())}function er(e){let t=[e.last_activity_at,e.last_status_changed_at,e.posted_at,e.created_at].map(e=>e?new Date(e).getTime():NaN).filter(e=>!Number.isNaN(e));return t.length>0?new Date(Math.max(...t)).toISOString():``}function tr(e,t,n){let r=e.status===`contacted`;if(e.status!==`recruiting`&&!r)return null;let i=Zt(r?e.last_status_changed_at||e.updated_at||e.created_at:er(e));return i===null||i<(r?n:t)?null:i}function nr(e){let t=String(e||``).toLowerCase();return t===`open`?`queued`:[`completed`,`closed`].includes(t)?`succeeded`:[`cancelled`,`canceled`].includes(t)?`failed`:`neutral`}function rr(e){let t=String(e||``).trim(),n=/^(\d{4})-(\d{2})-(\d{2})$/.exec(t);if(!n)return Y(e);let[,r,i,a]=n;return new Date(Number(r),Number(i)-1,Number(a)).toLocaleDateString(void 0,{year:`numeric`,month:`short`,day:`numeric`})}function ir(e){return e.full_name||e.email||e.source_user_id||`Unknown`}function ar(e){let t=e.trim();if(!t)return;let n=Number(t);return Number.isFinite(n)?n:void 0}function or(e){let t=e.wikiMatches?.matches||[],[n,r]=(0,l.useState)([]),[i,a]=(0,l.useState)(``),[o,s]=(0,l.useState)(``),[c,u]=(0,l.useState)(!1),[d,f]=(0,l.useState)(!1),p=(0,l.useMemo)(()=>e.projects.map(e=>e.id),[e.projects]),m=(0,l.useMemo)(()=>new Set(p),[p]),g=n.filter(e=>m.has(e)),_=e.projects.length>0&&g.length===e.projects.length;(0,l.useEffect)(()=>{r(e=>e.filter(e=>m.has(e)))},[m]);function v(e,t){r(n=>t?Array.from(new Set([...n,e])):n.filter(t=>t!==e))}async function b(){let t={};i&&(t.status=i),o&&(t.project_type=o),await e.onBulkUpdate(g,t)&&(r([]),a(``),s(``),u(!1))}let x=(0,M.jsxs)(G,{className:`grid gap-3 p-4 md:grid-cols-[minmax(0,1fr)_180px_auto_auto_auto] md:items-end`,children:[(0,M.jsxs)(q,{children:[`Search projects`,(0,M.jsx)(K,{id:`projectQuery`,value:e.query,autoComplete:`off`,placeholder:`Project, customer, ERP id`,onChange:t=>e.setQuery(t.target.value),onKeyDown:t=>t.key===`Enter`&&e.onSearch()})]}),(0,M.jsxs)(q,{children:[`Status`,(0,M.jsxs)(J,{id:`projectStatus`,value:e.status,onChange:t=>e.setStatus(t.target.value),children:[(0,M.jsx)(`option`,{value:`Open`,children:`Open`}),(0,M.jsx)(`option`,{value:``,children:`Any status`})]})]}),(0,M.jsxs)(W,{id:`refreshProjects`,type:`button`,onClick:e.onSearch,disabled:e.loading.projects,children:[(0,M.jsx)(C,{}),`Refresh`]}),e.canSync?(0,M.jsxs)(W,{id:`syncProjects`,type:`button`,variant:`outline`,onClick:e.onSync,disabled:e.loading.syncProjects,children:[(0,M.jsx)(C,{}),`Sync ERP`]}):null,(0,M.jsxs)(W,{id:`wikiProjectMatches`,type:`button`,variant:`outline`,onClick:e.onWikiMatches,disabled:e.loading.wikiMatches,children:[(0,M.jsx)(w,{}),`Wiki match`]})]});return e.selectedProjectId&&!e.selectedProject&&e.loading.projects?(0,M.jsxs)(M.Fragment,{children:[x,(0,M.jsxs)(G,{children:[(0,M.jsx)(qt,{children:(0,M.jsx)(Jt,{children:`Project detail`})}),(0,M.jsx)(Yt,{className:`text-sm text-muted-foreground`,children:`Loading project.`})]})]}):e.selectedProjectId&&!e.selectedProject?(0,M.jsxs)(M.Fragment,{children:[x,(0,M.jsxs)(G,{children:[(0,M.jsx)(qt,{children:(0,M.jsx)(Jt,{children:`Project detail`})}),(0,M.jsxs)(Yt,{className:`grid gap-3`,children:[(0,M.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`This project is not in the current result set. Clear filters or refresh the project list.`}),(0,M.jsxs)(W,{type:`button`,variant:`outline`,onClick:e.onCloseProject,children:[(0,M.jsx)(h,{}),`Back to projects`]})]})]})]}):e.selectedProject?(0,M.jsxs)(M.Fragment,{children:[x,(0,M.jsx)(cr,{project:e.selectedProject,loading:e.loading,canWrite:e.canWrite,crmContactUrl:e.crmContactUrl,onBack:e.onCloseProject,onUpdateStatus:e.onUpdateStatus,onAddUser:e.onAddUser,onRemoveUser:e.onRemoveUser,onAddHistoricalMember:e.onAddHistoricalMember,onRemoveHistoricalMember:e.onRemoveHistoricalMember})]}):(0,M.jsxs)(M.Fragment,{children:[x,(0,M.jsxs)(`section`,{className:`grid gap-3 md:grid-cols-2`,"aria-label":`Project summary`,children:[(0,M.jsx)(Wn,{id:`projectMetricOpen`,label:`Open`,value:e.summary.open_project_count||0}),(0,M.jsx)(Wn,{id:`projectMetricTotal`,label:`Projects`,value:e.summary.project_count||0})]}),e.canWrite?(0,M.jsxs)(G,{className:`flex flex-wrap items-center justify-between gap-3 p-4`,children:[(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Selected`}),(0,M.jsxs)(`strong`,{className:`block`,children:[g.length,` project(s)`]})]}),(0,M.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,M.jsxs)(W,{type:`button`,onClick:()=>f(!0),children:[(0,M.jsx)(S,{}),`New project`]}),(0,M.jsx)(W,{type:`button`,variant:`outline`,disabled:g.length===0,onClick:()=>u(!0),children:`Bulk edit`})]})]}):null,d?(0,M.jsx)(sr,{loading:e.loading.createProject,onClose:()=>f(!1),onSearchCustomers:e.onSearchCustomers,onSearchContacts:e.onSearchContacts,onSearchAccountManagers:e.onSearchAccountManagers,onLoadCostCenters:e.onLoadCostCenters,onCreateProject:e.onCreateProject}):null,c?(0,M.jsxs)(`div`,{className:`fixed inset-0 z-50 grid place-items-center p-4`,"aria-labelledby":`bulkProjectEditTitle`,"aria-modal":`true`,role:`dialog`,children:[(0,M.jsx)(`button`,{type:`button`,className:`absolute inset-0 cursor-default bg-black/45`,"aria-label":`Close bulk project edit`,onClick:()=>u(!1)}),(0,M.jsxs)(`div`,{className:`relative grid w-full max-w-lg gap-4 rounded-md border bg-background p-5 shadow-2xl`,children:[(0,M.jsxs)(`div`,{className:`flex items-start justify-between gap-3`,children:[(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`strong`,{id:`bulkProjectEditTitle`,className:`block text-base`,children:`Bulk edit projects`}),(0,M.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[g.length,` selected`]})]}),(0,M.jsx)(W,{type:`button`,variant:`ghost`,size:`icon`,"aria-label":`Close bulk project edit`,onClick:()=>u(!1),children:(0,M.jsx)(E,{})})]}),(0,M.jsxs)(`div`,{className:`grid gap-3`,children:[(0,M.jsx)(`strong`,{className:`text-sm`,children:`Changes`}),(0,M.jsxs)(q,{children:[`Status`,(0,M.jsxs)(J,{value:i,onChange:e=>a(e.target.value),children:[(0,M.jsx)(`option`,{value:``,children:`No change`}),(0,M.jsx)(`option`,{value:`Open`,children:`Open`}),(0,M.jsx)(`option`,{value:`Completed`,children:`Completed`}),(0,M.jsx)(`option`,{value:`Cancelled`,children:`Cancelled`})]})]}),(0,M.jsxs)(q,{children:[`ERP Type`,(0,M.jsxs)(J,{value:o,onChange:e=>s(e.target.value),children:[(0,M.jsx)(`option`,{value:``,children:`No change`}),(0,M.jsx)(`option`,{value:`Internal`,children:`Internal`}),(0,M.jsx)(`option`,{value:`External`,children:`External`})]})]})]}),(0,M.jsxs)(`div`,{className:`flex flex-wrap justify-end gap-2`,children:[(0,M.jsx)(W,{type:`button`,variant:`outline`,onClick:()=>u(!1),children:`Cancel`}),(0,M.jsx)(W,{type:`button`,disabled:e.loading.projectsBulkUpdate||g.length===0||!i&&!o,onClick:()=>void b(),children:`Apply changes`})]})]})]}):null,(0,M.jsxs)(G,{children:[(0,M.jsxs)(qt,{children:[(0,M.jsx)(Jt,{children:`ERP projects`}),(0,M.jsx)(`span`,{id:`projectsStatus`,className:`text-sm text-muted-foreground`,children:e.loading.projects?`Loading`:`${e.projects.length} shown | synced ${Y(e.summary.last_synced_at)}`})]}),(0,M.jsx)(N,{hidden:e.projects.length!==0,children:`No projects match this view. Sync ERP projects if the cache is empty.`}),(0,M.jsx)(`div`,{className:`overflow-x-auto`,children:(0,M.jsxs)(zt,{id:`projectsTable`,className:Rt(`min-w-[1100px]`,e.projects.length===0&&`hidden`),"aria-label":`ERP projects`,children:[(0,M.jsx)(Bt,{children:(0,M.jsxs)(z,{children:[e.canWrite?(0,M.jsx)(B,{className:`w-[48px]`,children:(0,M.jsx)(`input`,{type:`checkbox`,"aria-label":`Select all visible projects`,checked:_,onChange:e=>{r(e.target.checked?p:[])}})}):null,(0,M.jsx)(H,{className:`w-[24%]`,label:`Project`,scope:`projects`,sort:e.sort,sortKey:`display_name`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[16%]`,label:`Customer`,scope:`projects`,sort:e.sort,sortKey:`customer`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[10%]`,label:`Status`,scope:`projects`,sort:e.sort,sortKey:`status`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(B,{className:`w-[16%]`,children:`Timeline`}),(0,M.jsx)(H,{className:`w-[10%]`,label:`Roster`,scope:`projects`,sort:e.sort,sortKey:`roster_count`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[14%]`,label:`Modified`,scope:`projects`,sort:e.sort,sortKey:`modified`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(B,{children:`ERP`})]})}),(0,M.jsx)(R,{id:`projectsBody`,children:e.projects.map(t=>{let n=t.roster_members||[];return(0,M.jsxs)(z,{children:[e.canWrite?(0,M.jsx)(V,{children:(0,M.jsx)(`input`,{type:`checkbox`,"aria-label":`Select ${t.display_name}`,checked:g.includes(t.id),onChange:e=>v(t.id,e.target.checked)})}):null,(0,M.jsxs)(V,{children:[(0,M.jsx)(`button`,{type:`button`,className:`text-left font-bold text-primary underline-offset-4 hover:underline`,onClick:()=>e.onOpenProject(t.id),children:t.display_name}),(0,M.jsxs)(`div`,{className:`mt-1 flex flex-wrap items-center gap-1.5`,children:[t.project_type?(0,M.jsx)(U,{variant:`neutral`,children:t.project_type}):null,t.linked_engagement_count?(0,M.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[t.linked_engagement_count,` linked gig`]}):null]})]}),(0,M.jsx)(V,{children:t.customer_erpnext_url?(0,M.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-semibold text-primary underline-offset-4 hover:underline`,href:t.customer_erpnext_url,target:`_blank`,rel:`noreferrer`,children:[t.customer,(0,M.jsx)(y,{className:`size-3.5`})]}):t.customer||`None`}),(0,M.jsx)(V,{children:(0,M.jsx)(U,{variant:nr(t.source_status),children:t.source_status||`Unknown`})}),(0,M.jsx)(V,{children:[t.actual_start_date,t.actual_end_date].filter(Boolean).map(e=>rr(e)).join(` to `)||`Not set`}),(0,M.jsx)(V,{children:(0,M.jsxs)(`div`,{className:`grid gap-1`,children:[(0,M.jsx)(`strong`,{children:n.length}),(0,M.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[n.map(ir).slice(0,4).join(`, `)||`No ERP roster`,n.length>4?` +${n.length-4}`:``]})]})}),(0,M.jsx)(V,{children:Y(t.source_modified_at)}),(0,M.jsx)(V,{className:`text-xs`,children:t.erpnext_project_url?(0,M.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-mono font-semibold text-primary underline-offset-4 hover:underline`,href:t.erpnext_project_url,target:`_blank`,rel:`noreferrer`,children:[t.erpnext_project_id,(0,M.jsx)(y,{className:`size-3.5`})]}):(0,M.jsx)(`span`,{className:`font-mono`,children:`Unlinked`})})]},t.id)})})]})})]}),e.wikiMatches?(0,M.jsxs)(G,{children:[(0,M.jsxs)(qt,{children:[(0,M.jsx)(Jt,{children:`Wiki match preview`}),(0,M.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[e.wikiMatches.document?.title||`Client & Project Info`,` |`,` `,Y(e.wikiMatches.document?.updatedAt)]})]}),(0,M.jsx)(`div`,{className:`overflow-x-auto`,children:(0,M.jsxs)(zt,{id:`wikiMatchesTable`,className:`min-w-[920px]`,"aria-label":`Wiki matches`,children:[(0,M.jsx)(Bt,{children:(0,M.jsxs)(z,{children:[(0,M.jsx)(B,{children:`ERP project`}),(0,M.jsx)(B,{children:`Best wiki row`}),(0,M.jsx)(B,{children:`Confidence`}),(0,M.jsx)(B,{children:`Section`}),(0,M.jsx)(B,{children:`Decision`})]})}),(0,M.jsx)(R,{children:t.map((t,n)=>{let r=t.project,i=t.best_match?.row||{},a=t.manual_match?.match_status||``,o=r?.id||i.row_key||[i.section,i.Client].filter(Boolean).join(`:`)||`wiki-match-${n}`;return(0,M.jsxs)(z,{children:[(0,M.jsx)(V,{children:r?.display_name||`Unknown`}),(0,M.jsxs)(V,{children:[(0,M.jsx)(`strong`,{children:i.Client||`No match`}),(0,M.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:[i.DRI,i.Members].filter(Boolean).join(` | `)})]}),(0,M.jsx)(V,{children:(0,M.jsx)(U,{variant:t.best_match?.confidence===`high`?`succeeded`:t.best_match?.confidence===`medium`?`running`:`neutral`,children:t.best_match?`${t.best_match.confidence} ${t.best_match.score}`:`none`})}),(0,M.jsx)(V,{children:i.section||``}),(0,M.jsx)(V,{children:(0,M.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[a?(0,M.jsx)(U,{variant:a===`confirmed`?`succeeded`:`neutral`,children:a===`no_row`?`No wiki row`:`Confirmed`}):null,e.canWrite&&r?.id?(0,M.jsxs)(M.Fragment,{children:[i.row_key?(0,M.jsx)(W,{type:`button`,variant:`outline`,size:`sm`,disabled:e.loading[`project:${r.id}:wiki`],onClick:()=>void e.onUpdateWikiMatch(r.id,`confirmed`,i.row_key),children:`Confirm`}):null,(0,M.jsx)(W,{type:`button`,variant:`outline`,size:`sm`,disabled:e.loading[`project:${r.id}:wiki`],onClick:()=>void e.onUpdateWikiMatch(r.id,`no_row`),children:`No row`})]}):null]})})]},o)})})]})})]}):null]})}function sr(e){let[t,n]=(0,l.useState)(``),[r,i]=(0,l.useState)(`new`),[a,o]=(0,l.useState)(``),[s,c]=(0,l.useState)(``),[u,d]=(0,l.useState)(``),[f,p]=(0,l.useState)([]),[m,h]=(0,l.useState)(``),[g,_]=(0,l.useState)(``),[v,y]=(0,l.useState)([]),[b,x]=(0,l.useState)(`USD`),[ee,te]=(0,l.useState)(``),[S,C]=(0,l.useState)(``),[w,ne]=(0,l.useState)(``),[re,ie]=(0,l.useState)(``),[ae,oe]=(0,l.useState)(``),[se,T]=(0,l.useState)(``),[D,O]=(0,l.useState)(`United States`),[ce,le]=(0,l.useState)(``),[ue,de]=(0,l.useState)(`new`),[k,A]=(0,l.useState)(``),[j,N]=(0,l.useState)(``),[fe,pe]=(0,l.useState)([]),[me,he]=(0,l.useState)(``),[ge,_e]=(0,l.useState)(``),[ve,ye]=(0,l.useState)(``),[be,xe]=(0,l.useState)(``),[Se,Ce]=(0,l.useState)(``),[we,Te]=(0,l.useState)(!1),[Ee,De]=(0,l.useState)([{name:`Projects - 5`,cost_center_name:`Projects`}]),[Oe,ke]=(0,l.useState)(`Projects - 5`),[Ae,je]=(0,l.useState)(``),[Me,Ne]=(0,l.useState)(!1),Pe=(0,l.useRef)(e.onSearchCustomers),Fe=(0,l.useRef)(e.onSearchContacts),Ie=(0,l.useRef)(e.onSearchAccountManagers),Le=(0,l.useRef)(e.onLoadCostCenters),Re=(0,l.useRef)(0),ze=(0,l.useRef)(0),P=(0,l.useRef)(0),Be=(0,l.useRef)(0),Ve=t.trim()?`Engineering for ${t.trim()}`.slice(0,140):``,He=[w,re,ae,se,ce].some(e=>e.trim()),Ue=[me,ge,ve,be,Se].some(e=>e.trim()),We=t.trim()&&(r===`new`?a.trim():u.trim())&&!e.loading;(0,l.useEffect)(()=>{Pe.current=e.onSearchCustomers},[e.onSearchCustomers]),(0,l.useEffect)(()=>{Fe.current=e.onSearchContacts},[e.onSearchContacts]),(0,l.useEffect)(()=>{Ie.current=e.onSearchAccountManagers},[e.onSearchAccountManagers]),(0,l.useEffect)(()=>{Le.current=e.onLoadCostCenters},[e.onLoadCostCenters]),(0,l.useEffect)(()=>{let e=!0,t=Re.current+1;return Re.current=t,Le.current().then(n=>{!e||Re.current!==t||(De(n),ke(e=>n.some(t=>t.name===e)?e:`Projects - 5`))}),()=>{e=!1}},[]),(0,l.useEffect)(()=>{if(r!==`existing`){ze.current+=1,p([]);return}let e=!0,t=ze.current+1;ze.current=t;let n=window.setTimeout(()=>{Pe.current(s).then(n=>{!e||ze.current!==t||p(n)})},250);return()=>{e=!1,window.clearTimeout(n)}},[r,s]),(0,l.useEffect)(()=>{if(r!==`new`){P.current+=1,y([]);return}let e=!0,t=P.current+1;P.current=t;let n=window.setTimeout(()=>{Ie.current(m).then(n=>{!e||P.current!==t||y(n)})},250);return()=>{e=!1,window.clearTimeout(n)}},[r,m]),(0,l.useEffect)(()=>{if(r!==`new`||ue!==`existing`){Be.current+=1,pe([]);return}let e=!0,t=Be.current+1;Be.current=t;let n=window.setTimeout(()=>{Fe.current(k).then(n=>{!e||Be.current!==t||pe(n)})},250);return()=>{e=!1,window.clearTimeout(n)}},[r,ue,k]);async function Ge(){We&&await e.onCreateProject({project_name:t.trim(),customer_mode:r,customer_name:r===`new`?a.trim():void 0,customer:r===`existing`?u.trim():void 0,account_manager:r===`new`&&g.trim()||void 0,default_billing_currency:r===`new`?b.trim()||`USD`:void 0,default_cost_center:Oe.trim()||`Projects - 5`,activity_type:Me&&Ae.trim()||void 0,customer_details:r===`new`&&ee.trim()||void 0,customer_website:r===`new`&&S.trim()||void 0,address_line1:r===`new`&&w.trim()||void 0,address_line2:r===`new`&&re.trim()||void 0,address_city:r===`new`&&ae.trim()||void 0,address_state:r===`new`&&se.trim()||void 0,address_country:r===`new`&&w.trim()?D.trim()||`United States`:void 0,address_postal_code:r===`new`&&ce.trim()||void 0,contact:r===`new`&&ue===`existing`&&j.trim()||void 0,contact_first_name:r===`new`&&ue===`new`&&me.trim()||void 0,contact_last_name:r===`new`&&ue===`new`&&ge.trim()||void 0,contact_email:r===`new`&&ue===`new`&&ve.trim()||void 0,contact_phone:r===`new`&&ue===`new`&&be.trim()||void 0,contact_mobile:r===`new`&&ue===`new`&&Se.trim()||void 0})&&e.onClose()}return(0,M.jsxs)(`div`,{className:`fixed inset-0 z-50 grid place-items-center p-4`,"aria-labelledby":`createProjectTitle`,"aria-modal":`true`,role:`dialog`,children:[(0,M.jsx)(`button`,{type:`button`,className:`absolute inset-0 cursor-default bg-black/45`,"aria-label":`Close project creation`,onClick:e.onClose}),(0,M.jsxs)(`div`,{className:`relative grid max-h-[90vh] w-full max-w-2xl gap-4 overflow-y-auto rounded-md border bg-background p-5 shadow-2xl`,children:[(0,M.jsxs)(`div`,{className:`flex items-start justify-between gap-3`,children:[(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`strong`,{id:`createProjectTitle`,className:`block text-base`,children:`New ERP project`}),(0,M.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:`Creates a project and links a new or existing customer.`})]}),(0,M.jsx)(W,{type:`button`,variant:`ghost`,size:`icon`,"aria-label":`Close project creation`,onClick:e.onClose,children:(0,M.jsx)(E,{})})]}),(0,M.jsxs)(`div`,{className:`grid gap-3`,children:[(0,M.jsxs)(q,{children:[`Project name *`,(0,M.jsx)(K,{value:t,autoComplete:`off`,maxLength:140,placeholder:`Acme Portal`,onChange:e=>n(e.target.value)})]}),(0,M.jsxs)(`div`,{className:`grid gap-2`,children:[(0,M.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Customer`}),(0,M.jsx)(`div`,{className:`grid grid-cols-2 gap-2`,children:[`new`,`existing`].map(e=>(0,M.jsx)(W,{type:`button`,variant:r===e?`default`:`outline`,onClick:()=>i(e),children:e===`new`?`New customer`:`Existing customer`},e))})]}),r===`new`?(0,M.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[(0,M.jsxs)(q,{className:`md:col-span-2`,children:[`Customer name *`,(0,M.jsx)(K,{value:a,autoComplete:`off`,maxLength:140,placeholder:`Acme`,onChange:e=>o(e.target.value)})]}),(0,M.jsxs)(q,{children:[`Account manager`,(0,M.jsx)(K,{value:m,autoComplete:`off`,placeholder:`Search @508.dev user`,onChange:e=>{h(e.target.value),_(``)}})]}),m.trim().length>=2?(0,M.jsx)(`div`,{className:`grid max-h-40 gap-2 overflow-y-auto rounded-md border p-2 md:col-span-2`,children:v.length?v.map(e=>{let t=e.email||e.name||``;return(0,M.jsxs)(`label`,{className:`flex cursor-pointer items-start gap-2 rounded-sm px-2 py-1.5 hover:bg-secondary`,children:[(0,M.jsx)(`input`,{type:`radio`,name:`erpAccountManager`,value:t,checked:g===t,onChange:()=>{_(t),h(t)}}),(0,M.jsxs)(`span`,{className:`grid gap-0.5 text-sm`,children:[(0,M.jsx)(`strong`,{children:e.full_name||t}),(0,M.jsx)(`span`,{className:`text-muted-foreground`,children:t})]})]},t)}):(0,M.jsx)(`span`,{className:`px-2 py-3 text-sm text-muted-foreground`,children:`No enabled @508.dev users found.`})}):null]}):(0,M.jsxs)(`div`,{className:`grid gap-3`,children:[(0,M.jsxs)(q,{children:[`Find customer *`,(0,M.jsx)(K,{value:s,autoComplete:`off`,placeholder:`Search customer`,onChange:e=>c(e.target.value)})]}),(0,M.jsx)(`div`,{className:`grid max-h-48 gap-2 overflow-y-auto rounded-md border p-2`,children:f.length?f.map(e=>{let t=e.name||e.customer_name||``;return(0,M.jsxs)(`label`,{className:`flex cursor-pointer items-start gap-2 rounded-sm px-2 py-1.5 hover:bg-secondary`,children:[(0,M.jsx)(`input`,{type:`radio`,name:`erpCustomer`,value:t,checked:u===t,onChange:()=>d(t)}),(0,M.jsxs)(`span`,{className:`grid gap-0.5 text-sm`,children:[(0,M.jsx)(`strong`,{children:e.customer_name||t}),(0,M.jsx)(`span`,{className:`text-muted-foreground`,children:[t,e.default_currency].filter(Boolean).join(` | `)})]})]},t)}):(0,M.jsx)(`span`,{className:`px-2 py-3 text-sm text-muted-foreground`,children:`Search at least two characters.`})})]}),r===`new`?(0,M.jsxs)(M.Fragment,{children:[(0,M.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[(0,M.jsxs)(q,{className:`md:col-span-2`,children:[`Customer details`,(0,M.jsx)(`textarea`,{value:ee,className:`min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]`,maxLength:2e3,placeholder:`More information`,onChange:e=>te(e.target.value)})]}),(0,M.jsxs)(q,{className:`md:col-span-2`,children:[`Website`,(0,M.jsx)(K,{value:S,autoComplete:`url`,placeholder:`https://example.com`,onChange:e=>C(e.target.value)})]})]}),(0,M.jsxs)(`div`,{className:`grid gap-3`,children:[(0,M.jsxs)(`div`,{className:`flex items-center justify-between gap-3`,children:[(0,M.jsx)(`strong`,{className:`text-sm text-foreground`,children:`Contact`}),(0,M.jsx)(`div`,{className:`grid grid-cols-2 gap-2`,children:[`new`,`existing`].map(e=>(0,M.jsx)(W,{type:`button`,size:`sm`,variant:ue===e?`default`:`outline`,onClick:()=>de(e),children:e===`new`?`New`:`Existing`},e))})]}),ue===`new`?(0,M.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[(0,M.jsxs)(q,{children:[`First name `,Ue?`*`:``,(0,M.jsx)(K,{value:me,autoComplete:`given-name`,onChange:e=>he(e.target.value)})]}),(0,M.jsxs)(q,{children:[`Last name`,(0,M.jsx)(K,{value:ge,autoComplete:`family-name`,onChange:e=>_e(e.target.value)})]}),(0,M.jsxs)(q,{children:[`Email`,(0,M.jsx)(K,{value:ve,type:`email`,autoComplete:`email`,onChange:e=>ye(e.target.value)})]}),(0,M.jsxs)(q,{children:[`Phone`,(0,M.jsx)(K,{value:be,type:`tel`,autoComplete:`tel`,onChange:e=>xe(e.target.value)})]}),(0,M.jsxs)(q,{children:[`Mobile`,(0,M.jsx)(K,{value:Se,type:`tel`,autoComplete:`tel`,onChange:e=>Ce(e.target.value)})]})]}):(0,M.jsxs)(`div`,{className:`grid gap-3`,children:[(0,M.jsxs)(q,{children:[`Find contact`,(0,M.jsx)(K,{value:k,autoComplete:`off`,placeholder:`Search name or email`,onChange:e=>A(e.target.value)})]}),(0,M.jsx)(`div`,{className:`grid max-h-48 gap-2 overflow-y-auto rounded-md border p-2`,children:fe.length?fe.map(e=>{let t=e.name||``,n=e.full_name||t,r=[{key:`company`,value:e.company_name},{key:`email`,value:e.email_id},{key:`phone`,value:e.phone},{key:`mobile`,value:e.mobile_no}].filter(e=>!!e.value);return(0,M.jsxs)(`label`,{className:`flex cursor-pointer items-start gap-2 rounded-sm px-2 py-1.5 hover:bg-secondary`,children:[(0,M.jsx)(`input`,{type:`radio`,name:`erpContact`,value:t,checked:j===t,onChange:()=>N(t)}),(0,M.jsxs)(`span`,{className:`grid gap-0.5 text-sm`,children:[(0,M.jsx)(`strong`,{children:(0,M.jsx)(Gn,{value:n,query:k})}),r.length?(0,M.jsx)(`span`,{className:`text-muted-foreground`,children:r.map((e,t)=>(0,M.jsxs)(`span`,{children:[t>0?` | `:``,(0,M.jsx)(Gn,{value:e.value,query:k})]},e.key))}):null]})]},t)}):(0,M.jsx)(`span`,{className:`px-2 py-3 text-sm text-muted-foreground`,children:`Search at least two characters.`})})]})]}),(0,M.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[(0,M.jsx)(`strong`,{className:`text-sm text-foreground md:col-span-2`,children:`Address`}),(0,M.jsxs)(q,{className:`md:col-span-2`,children:[`Address line 1 `,He?`*`:``,(0,M.jsx)(K,{value:w,autoComplete:`address-line1`,onChange:e=>ne(e.target.value)})]}),(0,M.jsxs)(q,{className:`md:col-span-2`,children:[`Address line 2`,(0,M.jsx)(K,{value:re,autoComplete:`address-line2`,onChange:e=>ie(e.target.value)})]}),(0,M.jsxs)(q,{children:[`City`,(0,M.jsx)(K,{value:ae,autoComplete:`address-level2`,onChange:e=>oe(e.target.value)})]}),(0,M.jsxs)(q,{children:[`State`,(0,M.jsx)(K,{value:se,autoComplete:`address-level1`,onChange:e=>T(e.target.value)})]}),(0,M.jsxs)(q,{children:[`Postal code`,(0,M.jsx)(K,{value:ce,autoComplete:`postal-code`,onChange:e=>le(e.target.value)})]}),(0,M.jsxs)(q,{children:[`Country`,(0,M.jsx)(K,{value:D,autoComplete:`country-name`,onChange:e=>O(e.target.value)})]})]})]}):null,(0,M.jsxs)(`div`,{className:`grid gap-3 rounded-md border p-3`,children:[(0,M.jsx)(W,{type:`button`,variant:`outline`,onClick:()=>Te(e=>!e),children:we?`Hide advanced`:`Show advanced`}),we?(0,M.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[r===`new`?(0,M.jsxs)(q,{children:[`Billing currency`,(0,M.jsx)(K,{value:b,autoComplete:`off`,maxLength:3,onChange:e=>x(e.target.value.toUpperCase())})]}):null,(0,M.jsxs)(q,{children:[`Cost center`,(0,M.jsx)(J,{value:Oe,onChange:e=>ke(e.target.value),children:Ee.map(e=>{let t=e.name||``;return(0,M.jsx)(`option`,{value:t,children:[t,e.company].filter(Boolean).join(` | `)},t)})})]}),(0,M.jsxs)(q,{children:[`Activity type`,(0,M.jsx)(K,{value:Me?Ae:Ve,autoComplete:`off`,maxLength:140,placeholder:Ve||`Engineering for project`,onChange:e=>{Ne(!0),je(e.target.value)}})]})]}):null]})]}),(0,M.jsxs)(`div`,{className:`flex flex-wrap justify-end gap-2`,children:[(0,M.jsx)(W,{type:`button`,variant:`outline`,onClick:e.onClose,children:`Cancel`}),(0,M.jsx)(W,{type:`button`,disabled:!We,onClick:()=>void Ge(),children:`Create project`})]})]})]})}function cr(e){let t=e.project,n=t.roster_members||[],[r,i]=(0,l.useState)(``),[a,o]=(0,l.useState)([]),[s,c]=(0,l.useState)(``),[u,d]=(0,l.useState)(``),[f,p]=(0,l.useState)(``),[m,g]=(0,l.useState)(``),_=[t.actual_start_date||t.expected_start_date,t.actual_end_date||t.expected_end_date].filter(Boolean).map(e=>rr(e)).join(` to `)||`Not set`,v=typeof t.percent_complete==`number`?`${Math.round(t.percent_complete)}%`:`Not set`,b=a.find(e=>e.candidate_id===s),x=r.trim().includes(`@`)?r.trim().length>=5:r.trim().length>=3,ee=!!(u.trim()||f.trim()||m.trim()),te=ar(f),S=ar(m),C=!!((f.trim()||m.trim())&&!u.trim()),w=!!(u.trim()&&(!f.trim()||!m.trim())),ne=!!(f.trim()&&te===void 0)||!!(m.trim()&&S===void 0),re=C||w||ne||te!==void 0&&te<0||S!==void 0&&S<0,ie=ee&&!re?{activity_type:u.trim(),billing_rate:te,costing_rate:S}:void 0;(0,l.useEffect)(()=>{if(!e.canWrite)return;let t=r.trim();if(s&&b&&t===(b.email||b.label||``))return;if(s&&c(``),!(t.includes(`@`)?t.length>=5:t.length>=3)){o([]);return}let n=new AbortController,i=window.setTimeout(()=>{X(`/dashboard/api/project-member-candidates?query=${encodeURIComponent(t)}`,{signal:n.signal}).then(e=>o(e)).catch(e=>{e instanceof DOMException&&e.name===`AbortError`||o([])})},500);return()=>{n.abort(),window.clearTimeout(i)}},[r,e.canWrite,b,s]);function ae(e){c(e.candidate_id),i(e.email||e.label||e.full_name||r)}return(0,M.jsxs)(M.Fragment,{children:[(0,M.jsxs)(G,{children:[(0,M.jsx)(qt,{children:(0,M.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-[auto_minmax(0,1fr)_auto] md:items-start`,children:[(0,M.jsxs)(W,{type:`button`,variant:`outline`,onClick:e.onBack,children:[(0,M.jsx)(h,{}),`Projects`]}),(0,M.jsxs)(`div`,{className:`min-w-0`,children:[(0,M.jsx)(Jt,{children:t.display_name}),(0,M.jsxs)(`div`,{className:`mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground`,children:[(0,M.jsx)(U,{variant:nr(t.source_status),children:t.source_status||`Unknown`}),t.erpnext_project_id?(0,M.jsx)(`span`,{className:`font-mono`,children:t.erpnext_project_id}):null,t.last_synced_at?(0,M.jsxs)(`span`,{children:[`Synced `,Y(t.last_synced_at)]}):null]})]}),(0,M.jsxs)(`div`,{className:`flex flex-wrap justify-start gap-2 md:justify-end`,children:[e.canWrite?(0,M.jsxs)(J,{className:`w-[160px]`,"aria-label":`Status for ${t.display_name}`,value:t.source_status||``,disabled:e.loading[`project:${t.id}:status`],onChange:n=>e.onUpdateStatus(t.id,n.target.value),children:[(0,M.jsx)(`option`,{value:`Open`,children:`Open`}),(0,M.jsx)(`option`,{value:`Completed`,children:`Completed`}),(0,M.jsx)(`option`,{value:`Cancelled`,children:`Cancelled`})]}):null,t.erpnext_project_url?(0,M.jsxs)(`a`,{className:`inline-flex min-h-9 items-center justify-center gap-2 rounded-md border bg-secondary px-3 text-sm font-semibold`,href:t.erpnext_project_url,target:`_blank`,rel:`noreferrer`,children:[(0,M.jsx)(y,{className:`size-4`}),`ERP project`]}):null,t.customer_erpnext_url?(0,M.jsxs)(`a`,{className:`inline-flex min-h-9 items-center justify-center gap-2 rounded-md border bg-secondary px-3 text-sm font-semibold`,href:t.customer_erpnext_url,target:`_blank`,rel:`noreferrer`,children:[(0,M.jsx)(y,{className:`size-4`}),`ERP customer`]}):null]})]})}),(0,M.jsxs)(Yt,{className:`grid gap-4 md:grid-cols-2 lg:grid-cols-4`,children:[(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Customer`}),(0,M.jsx)(`strong`,{className:`block`,children:t.customer||`None`})]}),(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Timeline`}),(0,M.jsx)(`strong`,{className:`block`,children:_})]}),(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Progress`}),(0,M.jsx)(`strong`,{className:`block`,children:v})]}),(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Linked Gigs`}),(0,M.jsx)(`strong`,{className:`block`,children:t.linked_engagement_count||0})]}),(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`ERP Type`}),(0,M.jsx)(`div`,{className:`mt-1`,children:t.project_type?(0,M.jsx)(U,{variant:`neutral`,children:t.project_type}):(0,M.jsx)(`strong`,{className:`block`,children:`Not set`})})]}),(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`ERP Modified`}),(0,M.jsx)(`strong`,{className:`block`,children:Y(t.source_modified_at)})]}),(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Cache ID`}),(0,M.jsx)(`strong`,{className:`block break-all font-mono text-xs`,children:t.id})]})]})]}),(0,M.jsxs)(G,{children:[(0,M.jsxs)(qt,{children:[(0,M.jsx)(Jt,{children:`Project roster`}),(0,M.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:n.length?`${n.length} synced ERP user${n.length===1?``:`s`}`:`No ERP roster`})]}),e.canWrite?(0,M.jsxs)(Yt,{className:`grid gap-3 border-b md:grid-cols-[minmax(260px,1fr)_minmax(180px,.7fr)_minmax(130px,.45fr)_minmax(130px,.45fr)_auto_auto] md:items-end`,children:[(0,M.jsxs)(`div`,{className:`relative`,children:[(0,M.jsxs)(q,{children:[`Person search`,(0,M.jsx)(K,{value:r,autoComplete:`off`,placeholder:`Search @508.dev person`,onChange:e=>i(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),a.length===1&&ae(a[0]))}})]}),x&&!s?(0,M.jsx)(`div`,{className:`absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border bg-background shadow-lg`,children:a.length?a.map(e=>(0,M.jsxs)(`button`,{type:`button`,className:`grid w-full gap-0.5 px-3 py-2 text-left hover:bg-secondary focus:bg-secondary focus:outline-none`,onClick:()=>ae(e),children:[(0,M.jsx)(`span`,{className:`truncate text-sm font-bold`,children:e.label||e.full_name||e.email||`Person`}),(0,M.jsx)(`span`,{className:`truncate text-xs text-muted-foreground`,children:[e.email,e.sources?.join(`, `)].filter(Boolean).join(` | `)})]},e.candidate_id)):(0,M.jsx)(`div`,{className:`px-3 py-2 text-sm text-muted-foreground`,children:`No verified @508.dev results`})}):null]}),(0,M.jsxs)(q,{children:[`Activity Type`,(0,M.jsx)(K,{value:u,autoComplete:`off`,placeholder:`Optional rate step`,onChange:e=>d(e.target.value)})]}),(0,M.jsxs)(q,{children:[`Billing rate`,(0,M.jsx)(K,{value:f,inputMode:`decimal`,autoComplete:`off`,placeholder:`USD/hr`,onChange:e=>p(e.target.value)})]}),(0,M.jsxs)(q,{children:[`Costing rate`,(0,M.jsx)(K,{value:m,inputMode:`decimal`,autoComplete:`off`,placeholder:`USD/hr`,onChange:e=>g(e.target.value)})]}),(0,M.jsxs)(W,{type:`button`,variant:`outline`,disabled:e.loading[`project:${t.id}:user`]||!s||!b?.email||re,onClick:()=>void e.onAddUser(t.id,b?.email||r,s,ie).then(e=>{e&&(i(``),o([]),c(``),d(``),p(``),g(``))}),children:[(0,M.jsx)(T,{}),`Add ERP user`]}),(0,M.jsxs)(W,{type:`button`,variant:`outline`,disabled:e.loading[`project:${t.id}:historical`]||!r.trim(),onClick:()=>void e.onAddHistoricalMember(t.id,r).then(e=>{e&&i(``)}),children:[(0,M.jsx)(T,{}),`Add historical`]})]}):null,(0,M.jsx)(`div`,{className:`overflow-x-auto`,children:(0,M.jsxs)(zt,{className:`min-w-[860px]`,"aria-label":`Project roster`,children:[(0,M.jsx)(Bt,{children:(0,M.jsxs)(z,{children:[(0,M.jsx)(B,{children:`Name`}),(0,M.jsx)(B,{children:`Email`}),(0,M.jsx)(B,{children:`ERP user`}),(0,M.jsx)(B,{children:`Links`}),(0,M.jsx)(B,{children:`Source`}),(0,M.jsx)(B,{children:`Last seen`}),e.canWrite?(0,M.jsx)(B,{children:`Actions`}):null]})}),(0,M.jsx)(R,{children:n.length?n.map(n=>{let r=ir(n),i=n.source_user_id||n.email||``,a=n.roster_kind===`historical`||n.source===`manual`;return(0,M.jsxs)(z,{children:[(0,M.jsx)(V,{children:(0,M.jsx)(`strong`,{children:n.full_name||n.email||n.source_user_id})}),(0,M.jsx)(V,{children:n.email||`None`}),(0,M.jsx)(V,{className:`font-mono text-xs`,children:n.erpnext_user_url?(0,M.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-semibold text-primary underline-offset-4 hover:underline`,href:n.erpnext_user_url,target:`_blank`,rel:`noreferrer`,children:[n.source_user_id||`ERP user`,(0,M.jsx)(y,{className:`size-3.5`})]}):n.source_user_id||`Unknown`}),(0,M.jsx)(V,{children:(0,M.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[n.supplier_erpnext_url?(0,M.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-semibold text-primary underline-offset-4 hover:underline`,href:n.supplier_erpnext_url,target:`_blank`,rel:`noreferrer`,children:[`Supplier`,(0,M.jsx)(y,{className:`size-3.5`})]}):null,n.crm_contact_id&&e.crmContactUrl(n.crm_contact_id)?(0,M.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-semibold text-primary underline-offset-4 hover:underline`,href:e.crmContactUrl(n.crm_contact_id),target:`_blank`,rel:`noreferrer`,children:[`CRM`,(0,M.jsx)(y,{className:`size-3.5`})]}):null,!n.supplier_erpnext_url&&!(n.crm_contact_id&&e.crmContactUrl(n.crm_contact_id))?(0,M.jsx)(`span`,{className:`text-muted-foreground`,children:`None`}):null]})}),(0,M.jsx)(V,{children:n.roster_kind||n.source||`ERP`}),(0,M.jsx)(V,{children:Y(n.last_seen_at)}),e.canWrite?(0,M.jsx)(V,{children:(0,M.jsxs)(W,{type:`button`,variant:`outline`,size:`sm`,disabled:!i||e.loading[`project:${t.id}:${a?`historical`:`user`}`],onClick:()=>{window.confirm(`Remove ${r} from this project roster?`)&&(a?e.onRemoveHistoricalMember(t.id,i):e.onRemoveUser(t.id,i))},children:[(0,M.jsx)(oe,{}),`Remove`]})}):null]},`${n.source||``}:${n.source_user_id||n.email}`)}):(0,M.jsx)(z,{children:(0,M.jsx)(V,{colSpan:e.canWrite?7:6,className:`text-sm text-muted-foreground`,children:`No roster rows have been synced for this project.`})})})]})})]})]})}function lr(e){let t=e.gigs.reduce((t,n)=>(t.total+=1,t.applications+=Number(n.application_count||0),t.interested+=Number(n.interested_count||0),tr(n,e.staleDays,e.contactedReminderDays)!==null&&(t.stale+=1),t),{total:0,applications:0,interested:0,stale:0}),n=e.leads.reduce((e,t)=>(e.total+=1,t.status===`pending`&&(e.pending+=1),t.status===`approved`&&(e.approved+=1),t.status===`posted`&&(e.posted+=1),e),{total:0,pending:0,approved:0,posted:0}),r=(0,M.jsxs)(G,{className:`grid gap-3 p-4`,children:[(0,M.jsxs)(`div`,{className:`inline-flex w-fit rounded-md border bg-background p-1`,role:`tablist`,"aria-label":`Gig views`,children:[(0,M.jsxs)(W,{id:`gigsTab`,type:`button`,size:`sm`,variant:e.activeTab===`gigs`?`default`:`ghost`,"aria-pressed":e.activeTab===`gigs`,onClick:()=>e.setActiveTab(`gigs`),children:[(0,M.jsx)(_,{}),`Gigs`]}),(0,M.jsxs)(W,{id:`gigLeadsTab`,type:`button`,size:`sm`,variant:e.activeTab===`leads`?`default`:`ghost`,"aria-pressed":e.activeTab===`leads`,onClick:()=>e.setActiveTab(`leads`),children:[(0,M.jsx)(v,{}),`Leads`]})]}),e.activeTab===`leads`?(0,M.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-[minmax(160px,.6fr)_auto_auto] md:items-end`,children:[(0,M.jsxs)(q,{children:[`Lead status`,(0,M.jsxs)(J,{id:`gigLeadStatus`,value:e.leadStatus,onChange:t=>e.setLeadStatus(t.target.value),children:[(0,M.jsx)(`option`,{value:`pending`,children:`Pending`}),(0,M.jsx)(`option`,{value:`approved`,children:`Approved`}),(0,M.jsx)(`option`,{value:`rejected`,children:`Rejected`}),(0,M.jsx)(`option`,{value:`posted`,children:`Posted`}),(0,M.jsx)(`option`,{value:`all`,children:`All statuses`})]})]}),(0,M.jsxs)(W,{id:`refreshGigLeads`,type:`button`,variant:`outline`,onClick:e.onRefresh,disabled:e.loading.gigLeads,children:[(0,M.jsx)(C,{}),`Refresh`]}),e.canManageLeads?(0,M.jsxs)(W,{id:`syncGigLeads`,type:`button`,onClick:e.onSyncLeads,disabled:e.loading.gigLeadsSync,children:[(0,M.jsx)(C,{}),`Scrape HN`]}):null]}):(0,M.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-[minmax(140px,.75fr)_minmax(220px,1.25fr)_auto_auto_auto] md:items-end`,children:[(0,M.jsxs)(q,{children:[`Status`,(0,M.jsxs)(J,{id:`gigStatus`,value:e.status,onChange:t=>e.setStatus(t.target.value),children:[(0,M.jsx)(`option`,{value:``,children:`Any status`}),Zn.map(e=>(0,M.jsx)(`option`,{value:e,children:$n(e)},e))]})]}),(0,M.jsxs)(q,{children:[`Search gigs`,(0,M.jsx)(K,{id:`gigQuery`,value:e.query,autoComplete:`off`,placeholder:`Title, gig text, #tag, @poster`,onChange:t=>e.setQuery(t.target.value),onKeyDown:t=>t.key===`Enter`&&e.onRefresh()})]}),e.canIncludeHistorical?(0,M.jsxs)(`label`,{className:`flex min-h-9 items-center gap-2 text-xs font-bold text-muted-foreground`,children:[(0,M.jsx)(`input`,{type:`checkbox`,checked:e.includeHistorical,onChange:t=>e.setIncludeHistorical(t.target.checked)}),`Include historical`]}):null,(0,M.jsxs)(W,{id:`searchGigs`,type:`button`,onClick:e.onRefresh,disabled:e.loading.gigs,children:[(0,M.jsx)(w,{}),`Search`]}),(0,M.jsxs)(W,{id:`refreshGigs`,type:`button`,variant:`outline`,onClick:e.onRefresh,disabled:e.loading.gigs,children:[(0,M.jsx)(C,{}),`Refresh`]}),e.gigs.length>=e.limit?(0,M.jsx)(W,{type:`button`,variant:`outline`,onClick:()=>e.setLimit(Math.min(e.limit+100,500)),disabled:e.loading.gigs||e.limit>=500,children:`Load more`}):null]})]});if(e.activeTab===`leads`)return(0,M.jsxs)(M.Fragment,{children:[r,(0,M.jsxs)(`section`,{className:`grid gap-3 md:grid-cols-4`,"aria-label":`Lead summary`,children:[(0,M.jsx)(Wn,{id:`gigLeadMetricTotal`,label:`Leads`,value:n.total}),(0,M.jsx)(Wn,{id:`gigLeadMetricPending`,label:`Pending`,value:n.pending}),(0,M.jsx)(Wn,{id:`gigLeadMetricApproved`,label:`Approved`,value:n.approved}),(0,M.jsx)(Wn,{id:`gigLeadMetricPosted`,label:`Posted`,value:n.posted})]}),(0,M.jsxs)(G,{children:[(0,M.jsxs)(qt,{children:[(0,M.jsx)(Jt,{children:`Sourced leads`}),(0,M.jsx)(`span`,{id:`gigLeadsStatus`,className:`text-sm text-muted-foreground`,children:e.loading.gigLeads?`Loading`:`${e.leads.length} shown`})]}),(0,M.jsx)(N,{hidden:e.leads.length!==0,children:`No sourced leads match this view.`}),(0,M.jsx)(`div`,{id:`gigLeadsBody`,className:Rt(`grid gap-3 p-4`,e.leads.length===0&&`hidden`),children:e.leads.map(t=>(0,M.jsx)(_r,{lead:t,loading:e.loading,canWrite:e.canManageLeads,jobPostChannels:e.jobPostChannels,onReviewLead:e.onReviewLead,onPostLead:e.onPostLead},t.id))})]})]});let i=e.selectedGigId?e.loading[`gig:${e.selectedGigId}:detail`]:!1;return e.selectedGigId&&!e.selectedGig&&(e.loading.gigs||i)?(0,M.jsxs)(M.Fragment,{children:[r,(0,M.jsxs)(G,{children:[(0,M.jsx)(qt,{children:(0,M.jsx)(Jt,{children:`Gig detail`})}),(0,M.jsx)(Yt,{className:`text-sm text-muted-foreground`,children:`Loading gig.`})]})]}):e.selectedGigId&&!e.selectedGig?(0,M.jsxs)(M.Fragment,{children:[r,(0,M.jsxs)(G,{children:[(0,M.jsx)(qt,{children:(0,M.jsx)(Jt,{children:`Gig detail`})}),(0,M.jsxs)(Yt,{className:`grid gap-3`,children:[(0,M.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`This gig is not in the current result set. Clear filters or refresh the gig list.`}),(0,M.jsxs)(W,{type:`button`,variant:`outline`,onClick:e.onCloseGig,children:[(0,M.jsx)(h,{}),`Back to gigs`]})]})]})]}):e.selectedGig?(0,M.jsxs)(M.Fragment,{children:[r,(0,M.jsx)(Sr,{gig:e.selectedGig,loading:e.loading,canWrite:e.canWrite,canSearchCandidates:e.canSearchCandidates,crmContactUrl:e.crmContactUrl,crmAttachmentUrl:e.crmAttachmentUrl,staleDays:e.staleDays,contactedReminderDays:e.contactedReminderDays,onBack:e.onCloseGig,onUpdateStatus:e.onUpdateStatus,onAddApplication:e.onAddApplication,onUpdateApplicationStatus:e.onUpdateApplicationStatus},e.selectedGig.id)]}):(0,M.jsxs)(M.Fragment,{children:[r,(0,M.jsxs)(`section`,{className:`grid gap-3 md:grid-cols-4`,"aria-label":`Gig summary`,children:[(0,M.jsx)(Wn,{id:`gigMetricTotal`,label:`Gigs`,value:t.total}),(0,M.jsx)(Wn,{id:`gigMetricCandidates`,label:`Candidates`,value:t.applications}),(0,M.jsx)(Wn,{id:`gigMetricInterested`,label:`Interested`,value:t.interested}),(0,M.jsx)(Wn,{id:`gigMetricStale`,label:`Needs update`,value:t.stale})]}),(0,M.jsxs)(G,{children:[(0,M.jsxs)(qt,{children:[(0,M.jsx)(Jt,{children:`Discord gigs`}),(0,M.jsxs)(`div`,{className:`flex flex-wrap items-center justify-end gap-2`,children:[(0,M.jsxs)(W,{type:`button`,variant:`ghost`,size:`sm`,onClick:()=>e.onSort(`activity`),"aria-label":`Sort gigs by activity`,children:[`Activity`,` `,e.sort.key===`activity`?e.sort.direction===`asc`?`↑`:`↓`:``]}),(0,M.jsxs)(W,{type:`button`,variant:`ghost`,size:`sm`,onClick:()=>e.onSort(`title`),"aria-label":`Sort gigs by title`,children:[`Title `,e.sort.key===`title`?e.sort.direction===`asc`?`↑`:`↓`:``]}),(0,M.jsx)(`span`,{id:`gigsStatus`,className:`text-sm text-muted-foreground`,children:e.loading.gigs?`Loading`:`${e.gigs.length} shown`})]})]}),(0,M.jsx)(N,{hidden:e.gigs.length!==0,children:`No gigs match this view.`}),(0,M.jsx)(`div`,{id:`gigsBody`,className:Rt(`grid gap-3 p-4`,e.gigs.length===0&&`hidden`),children:e.gigs.map(t=>(0,M.jsx)(yr,{gig:t,loading:e.loading,canWrite:e.canWrite,staleDays:e.staleDays,contactedReminderDays:e.contactedReminderDays,onOpenGig:e.onOpenGig,onUpdateStatus:e.onUpdateStatus},t.id))})]})]})}function ur(e,t){if(t.length===0)return``;let n=String(e.posting_type||``).trim();return(t.find(e=>e.posting_type===n)||t.find(e=>e.posting_type===`part_time`)||t.find(e=>e.posting_type===`part_time_or_full_time`)||t.find(e=>e.posting_type===`unknown`)||t[0])?.channel_id||``}function dr(e){let t=e.channel_name?`#${e.channel_name}`:`#${e.channel_id}`;return`${$n(e.posting_type)} ${t}`}function fr(e){return String(e||``).trim().toLowerCase().replace(/[^a-z0-9]+/g,` `).trim()}function pr(e){let t=new Set((e.tags||[]).map(fr).filter(Boolean));e.remote&&t.add(`remote`);let n=String(e.posting_type||``);if(n===`part_time`)for(let e of[`part time`,`contract`,`contractor`,`freelance`,`gig`,`1099`])t.add(e);if(n===`full_time`)for(let e of[`full time`,`fulltime`,`employee`,`permanent`])t.add(e);if(n===`part_time_or_full_time`)for(let e of[`part time`,`full time`,`contract`,`employee`])t.add(e);return t.has(`contract to hire`)&&(t.add(`contract`),t.add(`contractor`)),t.has(`1099`)&&(t.add(`contract`),t.add(`contractor`)),t}function mr(e,t,n){let r=fr(e.name);if(!r)return-1;if(n.has(r))return 100;let i=fr([t.title,t.body_normalized,t.location,...t.tags||[]].join(` `));if(r===`remote`&&t.remote)return 90;if(i.includes(r))return 80;let a=String(t.posting_type||``);if(a===`part_time`&&[`part time`,`contract`,`contractor`,`freelance`,`gig`,`1099`].includes(r)||a===`full_time`&&[`full time`,`fulltime`,`employee`,`permanent`].includes(r))return 70;let o=new Set(Array.from(n).join(` `).split(` `).filter(Boolean));return r.split(` `).some(e=>o.has(e))?40:0}function hr(e,t){let n=t?.available_tags||[];if(n.length===0)return[];let r=pr(e),i=n.filter(e=>r.has(fr(e.name))).map(e=>e.name).slice(0,5);if(i.length>0||!t?.requires_tag)return i;let[a]=[...n].sort((t,n)=>{let i=mr(n,e,r)-mr(t,e,r);return i===0?Number(!!t.moderated)-Number(!!n.moderated):i});return a?[a.name]:[]}function gr(e){let t=e.contractor_classification?.method,n=e.contractor_classification?.posting_type||e.posting_type,r={part_time:`Part-time / contract`,full_time:`Full-time`,part_time_or_full_time:`Full-time or part-time / contract`,unknown:`Employment type unknown`}[String(n||``)]||`Employment type unknown`;return t?`${r} · ${en(t)}`:n?r:e.review_summary||`Classification unavailable`}function _r({lead:e,loading:t,canWrite:n,jobPostChannels:r,onReviewLead:i,onPostLead:a}){let o=n&&(e.status===`pending`||e.status===`approved`),s=t[`gigLead:${e.id}:review`],c=t[`gigLead:${e.id}:post`],u=o,d=ur(e,r),[f,p]=(0,l.useState)(`lead`),m=(0,l.useRef)(null),[h,g]=(0,l.useState)(d),_=r.find(e=>e.channel_id===h),[v,b]=(0,l.useState)(()=>hr(e,_));(0,l.useEffect)(()=>{g(d)},[d]),(0,l.useEffect)(()=>{b(hr(e,_))},[e,_]);let x=!!_?.requires_tag&&!!_?.available_tags?.length&&v.length===0;function ee(e){b(t=>t.includes(e)?t.filter(t=>t!==e):[...t,e].slice(0,5))}let te=e.discord_guild_id&&e.discord_thread_id?`https://discord.com/channels/${encodeURIComponent(e.discord_guild_id)}/${encodeURIComponent(e.discord_thread_id)}`:``,S=e.contractor_classification?.contact_email;return(0,M.jsxs)(`article`,{className:`grid gap-4 rounded-md border bg-background p-4 lg:grid-cols-[minmax(0,1fr)_220px_190px] lg:items-start`,children:[(0,M.jsxs)(`div`,{className:`min-w-0`,children:[(0,M.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[(0,M.jsx)(`strong`,{className:`text-base`,children:e.title||`Untitled lead`}),(0,M.jsx)(U,{variant:vr(e.status),children:$n(e.status)}),e.remote?(0,M.jsx)(U,{variant:`queued`,children:`Remote`}):null,(0,M.jsx)(U,{variant:`neutral`,children:gr(e)})]}),(0,M.jsxs)(`div`,{className:`mt-2 flex flex-wrap gap-1.5`,children:[e.organization?(0,M.jsx)(U,{variant:`neutral`,children:e.organization}):null,e.location?(0,M.jsx)(U,{variant:`neutral`,children:e.location}):null,(e.tags||[]).slice(0,6).map(e=>(0,M.jsx)(U,{variant:`queued`,children:e},e))]}),(0,M.jsx)(`p`,{className:`mt-3 max-h-20 overflow-hidden text-sm text-muted-foreground`,children:e.body_normalized||`No lead text captured.`}),e.review_summary?(0,M.jsx)(`p`,{className:`mt-2 text-sm font-semibold text-foreground`,children:e.review_summary}):null,(0,M.jsxs)(`div`,{className:`mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground`,children:[(0,M.jsxs)(`span`,{children:[`Posted `,Y(e.source_posted_at)||`unknown`]}),(0,M.jsxs)(`span`,{children:[`Captured `,Y(e.created_at)||`unknown`]}),e.source_url?(0,M.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-extrabold text-primary`,href:e.source_url,target:`_blank`,rel:`noreferrer`,children:[`Source`,(0,M.jsx)(y,{className:`size-3.5`})]}):null,e.apply_url?(0,M.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-extrabold text-primary`,href:e.apply_url,target:`_blank`,rel:`noreferrer`,children:[`Apply website`,(0,M.jsx)(y,{className:`size-3.5`})]}):null,S?(0,M.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-extrabold text-primary`,href:`mailto:${S}`,children:[`Email `,S]}):null,te?(0,M.jsxs)(`a`,{className:`inline-flex items-center gap-1 font-extrabold text-primary`,href:te,target:`_blank`,rel:`noreferrer`,children:[`Discord`,(0,M.jsx)(y,{className:`size-3.5`})]}):null]})]}),(0,M.jsxs)(`div`,{className:`grid gap-1 text-sm`,children:[(0,M.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Review`}),(0,M.jsx)(`span`,{children:e.reviewed_at?Y(e.reviewed_at):`Not reviewed`}),(0,M.jsx)(`span`,{className:`text-muted-foreground`,children:e.reviewed_by_discord_user_id?`By ${e.reviewed_by_discord_user_id}`:`No reviewer recorded`}),(0,M.jsx)(`span`,{className:`font-mono text-xs text-muted-foreground`,children:e.id.slice(0,8)})]}),n?(0,M.jsxs)(`div`,{className:`grid gap-2`,children:[(0,M.jsxs)(q,{children:[`Post as`,(0,M.jsxs)(J,{value:f,ref:m,disabled:!u||c||s,onChange:e=>p(e.target.value===`recruiting`?`recruiting`:`lead`),children:[(0,M.jsx)(`option`,{value:`lead`,children:`Lead`}),(0,M.jsx)(`option`,{value:`recruiting`,children:`Recruiting`})]})]}),r.length>0?(0,M.jsxs)(q,{children:[`Channel`,(0,M.jsx)(J,{value:h,disabled:!u||c||s,onChange:e=>g(e.target.value),children:r.map(e=>(0,M.jsx)(`option`,{value:e.channel_id,children:dr(e)},e.channel_id))})]}):null,_?.available_tags?.length?(0,M.jsxs)(`fieldset`,{className:`grid gap-1.5`,children:[(0,M.jsxs)(`legend`,{className:`text-xs font-bold text-muted-foreground`,children:[`Tags`,_.requires_tag?` (required)`:``]}),(0,M.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:_.available_tags.map(e=>(0,M.jsxs)(`label`,{className:`inline-flex min-h-8 items-center gap-1.5 rounded-md border px-2 text-xs font-bold text-muted-foreground`,children:[(0,M.jsx)(`input`,{type:`checkbox`,checked:v.includes(e.name),disabled:!u||c||s,onChange:()=>ee(e.name)}),e.name]},e.id||e.name))})]}):null,(0,M.jsxs)(W,{type:`button`,disabled:!u||c||s||x,onClick:()=>{let t=m.current?.value===`recruiting`?`recruiting`:`lead`;a(e.id,{channelId:h,engagementStatus:t,tags:v})},children:[(0,M.jsx)(ne,{}),`Post to Discord`]}),(0,M.jsxs)(W,{type:`button`,variant:`outline`,disabled:!o||s||c,onClick:()=>i(e.id,`rejected`),children:[(0,M.jsx)(oe,{}),`Reject`]})]}):null]})}function vr(e){return e===`approved`?`queued`:e===`posted`?`succeeded`:e===`rejected`?`failed`:`neutral`}function yr({gig:e,loading:t,canWrite:n,onOpenGig:r,onUpdateStatus:i,staleDays:a,contactedReminderDays:o}){let s=Array.isArray(e.applications)?e.applications:[],c=e.status===`recruiting`||e.status===`contacted`,l=e.discord_guild_id&&e.discord_thread_id?`https://discord.com/channels/${encodeURIComponent(e.discord_guild_id)}/${encodeURIComponent(e.discord_thread_id)}`:``,u=tr(e,a,o);return(0,M.jsxs)(`article`,{className:Rt(`grid gap-4 rounded-md border bg-background p-4 lg:grid-cols-[minmax(0,1fr)_220px_180px] lg:items-start`,!c&&`border-l-4 border-l-muted-foreground/60 bg-secondary/45`),children:[(0,M.jsxs)(`div`,{className:`min-w-0`,children:[(0,M.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[(0,M.jsx)(`a`,{className:`text-base font-extrabold text-primary`,href:`/dashboard/gigs/${encodeURIComponent(e.id)}`,onClick:t=>{t.preventDefault(),r(e.id)},children:e.title||`Untitled gig`}),(0,M.jsx)(U,{variant:e.status===`filled`?`succeeded`:e.status===`lost`||e.status===`duplicate`?`failed`:c?`queued`:`neutral`,children:e.status_label||$n(e.status)}),c?null:(0,M.jsx)(U,{variant:`neutral`,children:`Not active`}),u===null?null:(0,M.jsxs)(U,{variant:`running`,children:[u,`d stale`]})]}),(0,M.jsxs)(`div`,{className:`mt-2 flex flex-wrap gap-1.5`,children:[e.posting_type?(0,M.jsx)(U,{variant:`neutral`,children:$n(e.posting_type)}):null,e.discord_channel_name?(0,M.jsxs)(U,{variant:`neutral`,children:[`#`,e.discord_channel_name]}):null,(e.required_skills||[]).slice(0,5).map(e=>(0,M.jsx)(U,{variant:`queued`,children:e},e)),(e.preferred_skills||[]).slice(0,3).map(e=>(0,M.jsx)(U,{variant:`neutral`,children:e},e))]}),(0,M.jsxs)(`div`,{className:`mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground`,children:[(0,M.jsxs)(`span`,{children:[`Activity `,Y(er(e))||`unknown`]}),(0,M.jsxs)(`span`,{children:[`Posted `,Y(e.posted_at)||`unknown`]}),l?(0,M.jsx)(`a`,{className:`font-extrabold text-primary`,href:l,target:`_blank`,rel:`noreferrer`,children:`Open Discord thread`}):null]})]}),(0,M.jsxs)(`div`,{className:`grid grid-cols-2 gap-2 text-sm lg:grid-cols-1`,children:[(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`span`,{className:`block text-xs font-bold text-muted-foreground`,children:`People`}),(0,M.jsx)(`strong`,{children:e.application_count||s.length}),(0,M.jsxs)(`span`,{className:`ml-2 text-muted-foreground`,children:[Number(e.interested_count||0),` interested`]})]}),(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`span`,{className:`block text-xs font-bold text-muted-foreground`,children:`Top candidates`}),(0,M.jsx)(`span`,{className:`text-muted-foreground`,children:s.slice(0,3).map(e=>br(e)).join(`, `)||`None yet`})]})]}),(0,M.jsxs)(`div`,{className:`grid gap-2`,children:[n?(0,M.jsx)(J,{"aria-label":`Status for ${e.title||`gig`}`,value:e.status,disabled:t[`gig:${e.id}:status`],onChange:t=>i(e.id,t.target.value),children:Zn.map(e=>(0,M.jsx)(`option`,{value:e,children:$n(e)},e))}):null,(0,M.jsx)(W,{type:`button`,onClick:()=>r(e.id),children:`Manage people`})]})]})}function br(e){return e.name||e.email_508||e.discord_username||(typeof e.evaluation?.discord_username==`string`?e.evaluation.discord_username:``)||`Candidate`}function xr(e){return e.name||e.email_508||e.email||e.crm_contact_id||`CRM person`}function Sr({gig:e,loading:t,canWrite:n,canSearchCandidates:r,crmContactUrl:i,crmAttachmentUrl:a,staleDays:o,contactedReminderDays:s,onBack:c,onUpdateStatus:u,onAddApplication:d,onUpdateApplicationStatus:f}){let[p,m]=(0,l.useState)(``),[g,_]=(0,l.useState)([]),[v,b]=(0,l.useState)(null),[x,ee]=(0,l.useState)(``),[te,S]=(0,l.useState)(``),C=Array.isArray(e.applications)?e.applications:[],w=e.status===`recruiting`||e.status===`contacted`,ne=p.trim().length>=2,re=v?.crm_contact_id||``,ie=re?i(re)||re:``,ae=r?ie:te.trim(),oe=e.discord_guild_id&&e.discord_thread_id?`https://discord.com/channels/${encodeURIComponent(e.discord_guild_id)}/${encodeURIComponent(e.discord_thread_id)}`:``,T=tr(e,o,s);(0,l.useEffect)(()=>{if(!n||!r)return;let e=p.trim();if(v&&e===xr(v)){_([]),ee(``);return}if(e.length<2){_([]),ee(``);return}let t=new AbortController,i=window.setTimeout(()=>{X(`/dashboard/api/people?${new URLSearchParams({limit:`8`,query:e}).toString()}`,{signal:t.signal}).then(e=>{_(e.filter(e=>!!e.crm_contact_id)),ee(``)}).catch(e=>{t.signal.aborted||e instanceof DOMException&&e.name===`AbortError`||(_([]),ee(In(e,`Unable to search candidates`)))})},300);return()=>{t.abort(),window.clearTimeout(i)}},[p,r,n,v]);function E(e){b(e),m(xr(e)),_([]),ee(``)}return(0,M.jsxs)(`div`,{className:`grid gap-5`,children:[(0,M.jsxs)(G,{className:Rt(!w&&`border-l-4 border-l-muted-foreground/60 bg-secondary/35`),children:[(0,M.jsxs)(qt,{className:`items-start`,children:[(0,M.jsxs)(`div`,{className:`grid gap-2`,children:[(0,M.jsxs)(W,{type:`button`,variant:`ghost`,size:`sm`,className:`w-fit`,onClick:c,children:[(0,M.jsx)(h,{}),`Back to gigs`]}),(0,M.jsxs)(`div`,{children:[(0,M.jsx)(Jt,{className:`text-xl`,children:e.title||`Untitled gig`}),(0,M.jsxs)(`div`,{className:`mt-2 flex flex-wrap gap-1.5`,children:[(0,M.jsx)(U,{variant:e.status===`filled`?`succeeded`:e.status===`lost`||e.status===`duplicate`?`failed`:w?`queued`:`neutral`,children:e.status_label||$n(e.status)}),w?null:(0,M.jsx)(U,{variant:`neutral`,children:`Not active`}),T===null?null:(0,M.jsxs)(U,{variant:`running`,children:[T,`d stale`]}),e.posting_type?(0,M.jsx)(U,{variant:`neutral`,children:$n(e.posting_type)}):null,e.discord_channel_name?(0,M.jsxs)(U,{variant:`neutral`,children:[`#`,e.discord_channel_name]}):null,(e.required_skills||[]).map(e=>(0,M.jsx)(U,{variant:`queued`,children:e},e)),(e.preferred_skills||[]).map(e=>(0,M.jsx)(U,{variant:`neutral`,children:e},e))]})]})]}),(0,M.jsxs)(`div`,{className:`grid min-w-[190px] gap-2`,children:[n?(0,M.jsxs)(q,{children:[`Gig status`,(0,M.jsx)(J,{"aria-label":`Status for ${e.title||`gig`}`,value:e.status,disabled:t[`gig:${e.id}:status`],onChange:t=>u(e.id,t.target.value),children:Zn.map(e=>(0,M.jsx)(`option`,{value:e,children:$n(e)},e))})]}):null,oe?(0,M.jsxs)(`a`,{className:`inline-flex min-h-9 items-center justify-center gap-2 rounded-md border bg-secondary px-3 text-sm font-semibold`,href:oe,target:`_blank`,rel:`noreferrer`,children:[(0,M.jsx)(y,{className:`size-4`}),`Discord thread`]}):null]})]}),(0,M.jsxs)(Yt,{className:`grid gap-4 lg:grid-cols-[1fr_1fr_1fr]`,children:[(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Activity`}),(0,M.jsx)(`strong`,{className:`block`,children:Y(er(e))||`unknown`}),(0,M.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[`Posted `,Y(e.posted_at)||`unknown`]})]}),(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`People`}),(0,M.jsx)(`strong`,{className:`block`,children:e.application_count||C.length}),(0,M.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[Number(e.interested_count||0),` interested`]})]}),(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:`Discord`}),(0,M.jsx)(`strong`,{className:`block`,children:e.discord_channel_name||`No channel`}),(0,M.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:e.discord_thread_id?`Thread ${e.discord_thread_id}`:`No thread`})]})]})]}),(0,M.jsxs)(G,{children:[(0,M.jsxs)(qt,{children:[(0,M.jsx)(Jt,{children:`People`}),(0,M.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[C.length,` candidate`,C.length===1?``:`s`]})]}),n?(0,M.jsxs)(`form`,{className:`grid gap-2 border-t p-4 md:grid-cols-[minmax(220px,1fr)_auto]`,onSubmit:t=>{t.preventDefault(),d(e.id,ae).then(e=>{e&&(m(``),_([]),b(null),S(``))})},children:[r?(0,M.jsxs)(`div`,{className:`relative min-w-0`,children:[(0,M.jsxs)(q,{children:[`Candidate`,(0,M.jsx)(K,{value:p,autoComplete:`off`,placeholder:`Search by name or email`,"aria-label":`Search candidates to add`,onChange:e=>{m(e.target.value),_([]),ee(``),b(null)},onKeyDown:e=>{e.key!==`Enter`||g.length!==1||(e.preventDefault(),E(g[0]))}})]}),ne&&!v?(0,M.jsx)(`div`,{className:`absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border bg-background shadow-lg`,children:x?(0,M.jsx)(`div`,{className:`px-3 py-2 text-sm text-destructive`,children:x}):g.length?g.map(e=>{let t=xr(e),n=[e.email_508||e.email,e.contact_type].filter(Boolean).join(` | `);return(0,M.jsxs)(`button`,{type:`button`,className:`grid w-full gap-0.5 px-3 py-2 text-left hover:bg-secondary focus:bg-secondary focus:outline-none`,onClick:()=>E(e),children:[(0,M.jsx)(`span`,{className:`truncate text-sm font-bold`,children:t}),n?(0,M.jsx)(`span`,{className:`truncate text-xs text-muted-foreground`,children:n}):null]},e.crm_contact_id)}):(0,M.jsx)(`div`,{className:`px-3 py-2 text-sm text-muted-foreground`,children:`No candidates match this search`})}):null]}):(0,M.jsxs)(q,{className:`min-w-0`,children:[`CRM profile`,(0,M.jsx)(K,{value:te,onChange:e=>S(e.target.value),placeholder:`https://crm.508.dev/#Contact/view/...`,"aria-label":`CRM profile for candidate`})]}),(0,M.jsxs)(W,{type:`submit`,className:`self-end`,disabled:t[`gig:${e.id}:addCandidate`]||!ae,children:[(0,M.jsx)(se,{}),`Add candidate`]})]}):null,(0,M.jsx)(N,{hidden:C.length!==0,children:`No suggested or interested people yet.`}),(0,M.jsx)(`div`,{className:Rt(`grid gap-3 p-4`,C.length===0&&`hidden`),children:C.map(r=>(0,M.jsx)(Cr,{gigId:e.id,application:r,loading:t,canWrite:n,crmContactUrl:i,crmAttachmentUrl:a,onUpdateApplicationStatus:f},r.id))})]})]})}function Cr({gigId:e,application:t,loading:n,canWrite:r,crmContactUrl:i,crmAttachmentUrl:a,onUpdateApplicationStatus:o}){let s=br(t),c=i(t.crm_contact_id),l=a(t.latest_resume_id),u=typeof t.fit_score==`number`?`${Math.round(t.fit_score)}/100`:typeof t.match_score==`number`?t.match_score.toFixed(1):``,d=typeof t.evaluation?.llm_summary==`string`?t.evaluation.llm_summary:``;return(0,M.jsxs)(`div`,{className:`grid gap-2 rounded-md border bg-background p-2`,children:[(0,M.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[c?(0,M.jsx)(`a`,{className:`font-extrabold text-primary`,href:c,target:`_blank`,rel:`noopener noreferrer`,children:s}):(0,M.jsx)(`strong`,{children:s}),(0,M.jsx)(U,{variant:t.status===`interested`?`succeeded`:`neutral`,children:$n(t.status)}),(0,M.jsx)(U,{variant:`neutral`,children:$n(t.source||`manual_add`)}),u?(0,M.jsxs)(`span`,{className:`text-xs font-bold text-muted-foreground`,children:[`Fit `,u]}):null,c?(0,M.jsx)(`a`,{className:`text-xs font-extrabold text-primary`,href:c,target:`_blank`,rel:`noopener noreferrer`,"aria-label":`Open ${s} CRM profile`,children:`CRM profile`}):null,l?(0,M.jsx)(`a`,{className:`text-xs font-extrabold text-primary`,href:l,target:`_blank`,rel:`noopener noreferrer`,children:`Resume`}):null]}),d?(0,M.jsx)(`div`,{className:`text-xs text-muted-foreground`,children:d}):null,r?(0,M.jsx)(J,{"aria-label":`Candidate status for ${s}`,value:t.status||`suggested`,disabled:n[`application:${t.id}:status`],onChange:n=>o(e,t.id,n.target.value),children:Qn.map(e=>(0,M.jsx)(`option`,{value:e,children:$n(e)},e))}):null]})}function wr(e){let t=kn[e.peopleFilterKind]?.options||[];return(0,M.jsxs)(G,{children:[(0,M.jsxs)(qt,{children:[(0,M.jsx)(Jt,{children:`People lookup`}),(0,M.jsxs)(`div`,{className:`flex flex-wrap items-center justify-end gap-2`,children:[e.canSync?(0,M.jsxs)(W,{id:`syncPeople`,"data-permission":`people:sync`,type:`button`,onClick:e.onSync,disabled:e.loading.syncPeople,children:[(0,M.jsx)(C,{}),`Sync people`]}):null,e.crmBaseUrl?(0,M.jsx)(`a`,{id:`crmHomeLink`,className:`text-sm font-extrabold text-primary`,href:e.crmBaseUrl,target:`_blank`,rel:`noreferrer`,children:`Open CRM`}):null,(0,M.jsx)(`span`,{id:`peopleStatus`,className:`text-sm text-muted-foreground`,children:e.loading.people?`Loading`:`${e.people.length} shown`})]})]}),(0,M.jsxs)(`div`,{className:`grid gap-3 border-b p-4 md:grid-cols-[minmax(0,1fr)_auto]`,children:[(0,M.jsxs)(q,{children:[`Search CRM people cache`,(0,M.jsx)(K,{id:`peopleQuery`,value:e.peopleQuery,autoComplete:`off`,placeholder:`Name, email, CRM id, Discord, resume`,onChange:t=>e.setPeopleQuery(t.target.value),onKeyDown:t=>{t.key===`Enter`&&e.onSearch()}})]}),(0,M.jsxs)(W,{id:`searchPeople`,type:`button`,onClick:e.onSearch,disabled:e.loading.people,children:[(0,M.jsx)(w,{}),`Search`]})]}),(0,M.jsxs)(`div`,{className:`grid gap-3 border-b bg-background p-4 md:grid-cols-[minmax(120px,.7fr)_minmax(150px,1fr)_minmax(150px,1fr)_auto]`,children:[(0,M.jsxs)(q,{children:[`Member`,(0,M.jsxs)(J,{id:`peopleMember`,value:e.peopleMember,onChange:t=>e.setPeopleMember(t.target.value),children:[(0,M.jsx)(`option`,{value:``,children:`Any`}),(0,M.jsx)(`option`,{value:`true`,children:`Member`}),(0,M.jsx)(`option`,{value:`false`,children:`Not member`})]})]}),(0,M.jsxs)(q,{children:[`Add filter`,(0,M.jsx)(J,{id:`peopleFilterKind`,value:e.peopleFilterKind,disabled:e.peopleFilterKeys.length===0,onChange:t=>e.setPeopleFilterKind(t.target.value),children:e.peopleFilterKeys.map(e=>(0,M.jsx)(`option`,{value:e,children:kn[e].label},e))})]}),(0,M.jsxs)(q,{children:[`Value`,(0,M.jsx)(J,{id:`peopleFilterValue`,value:e.peopleFilterValue,onChange:t=>e.setPeopleFilterValue(t.target.value),children:t.map(([e,t])=>(0,M.jsx)(`option`,{value:e,children:t},e))})]}),(0,M.jsx)(W,{id:`addPeopleFilter`,type:`button`,onClick:e.addFilter,disabled:e.peopleFilterKeys.length===0,children:`Add filter`}),(0,M.jsx)(`div`,{id:`activePeopleFilters`,className:`md:col-span-4`,children:(0,M.jsx)(Xn,{filters:e.peopleFilters,onRemove:e.removeFilter})})]}),(0,M.jsx)(N,{hidden:e.people.length!==0,children:`No people match this lookup.`}),(0,M.jsx)(`div`,{className:`overflow-x-auto`,children:(0,M.jsxs)(zt,{id:`peopleTable`,className:Rt(`min-w-[900px]`,e.people.length===0&&`hidden`),"aria-label":`People lookup results`,children:[(0,M.jsx)(Bt,{children:(0,M.jsxs)(z,{children:[(0,M.jsx)(H,{className:`w-[27%]`,label:`Name`,scope:`people`,sort:e.sort,sortKey:`name`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[28%]`,label:`Status`,scope:`people`,sort:e.sort,sortKey:`status`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[20%]`,label:`Discord`,scope:`people`,sort:e.sort,sortKey:`discord`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[25%]`,label:`Resume / skills`,scope:`people`,sort:e.sort,sortKey:`resume`,onSort:(t,n)=>e.onSort(n)})]})}),(0,M.jsx)(R,{id:`peopleBody`,children:e.people.map(t=>{let n=t.name||t.email_508||t.email||`CRM contact`,r=e.crmContactUrl(t.crm_contact_id),i=t.profile_status||{},a=Number(i.skills_count||0),o=e.crmAttachmentUrl(t.latest_resume_id),s=t.latest_intake_submission,c=t.latest_resume_intake_submission||s,l=Mr(c),u=o||l,d=o?`Resume`:Nr(c)||t.latest_resume_name||`Resume`;return(0,M.jsxs)(z,{children:[(0,M.jsxs)(V,{children:[r?(0,M.jsx)(`a`,{className:`font-extrabold text-primary`,href:r,target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${n} in CRM`,children:n}):(0,M.jsx)(`strong`,{children:n}),(0,M.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:[t.email_508||t.email,t.contact_type].filter(Boolean).join(` | `)})]}),(0,M.jsx)(V,{children:(0,M.jsxs)(`div`,{className:`flex flex-wrap gap-1.5`,children:[i.crm_active?null:(0,M.jsx)(U,{variant:`missing`,children:t.sync_status||`CRM sync issue`}),(0,M.jsx)(U,{variant:i.is_member?`succeeded`:`missing`,children:i.is_member?`Member`:`Missing Member`}),(0,M.jsx)(U,{variant:i.discord_linked?`succeeded`:`missing`,children:i.discord_linked?`Discord`:`Missing Discord`}),(0,M.jsx)(U,{variant:i.email_508?`succeeded`:`missing`,children:i.email_508?`508 email`:`Missing 508 email`}),i.latest_resume?null:(0,M.jsx)(U,{variant:`missing`,children:`Missing Resume`})]})}),(0,M.jsx)(V,{children:[t.discord_username,t.discord_user_id].filter(Boolean).join(` | `)||`Not linked`}),(0,M.jsx)(V,{children:(0,M.jsxs)(`div`,{className:`flex flex-wrap items-center gap-1.5`,children:[u?(0,M.jsx)(`a`,{className:`inline-flex min-h-7 max-w-40 items-center truncate rounded-md border bg-secondary px-2 text-xs font-extrabold`,href:u,target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${n} resume`,title:d,children:d}):(0,M.jsx)(`span`,{children:t.latest_resume_name||t.latest_resume_id||`No resume`}),(0,M.jsx)(U,{variant:a>0?`succeeded`:`missing`,children:a>0?`Skills parsed`:`Skills not parsed`})]})})]},t.crm_contact_id||n)})})]})})]})}function Tr(e){let t=kn[e.onboardingFilterKind]?.options||[];return(0,M.jsxs)(M.Fragment,{children:[e.canWrite?(0,M.jsx)(Fr,{loading:e.loading.engineerSetup,onSetup:e.onSetupEngineer}):null,(0,M.jsxs)(G,{children:[(0,M.jsxs)(qt,{children:[(0,M.jsx)(Jt,{children:`Onboarding queue`}),(0,M.jsx)(`span`,{id:`onboardingStatus`,className:`text-sm text-muted-foreground`,children:e.loading.onboarding?`Loading`:`${e.people.length} shown`})]}),(0,M.jsxs)(`div`,{className:`grid gap-3 border-b p-4 md:grid-cols-[minmax(0,1fr)_auto]`,children:[(0,M.jsxs)(q,{children:[`Search prospects`,(0,M.jsx)(K,{id:`onboardingQuery`,value:e.onboardingQuery,autoComplete:`off`,placeholder:`Name, email, Discord, onboarder`,onChange:t=>e.setOnboardingQuery(t.target.value),onKeyDown:t=>t.key===`Enter`&&e.onSearch()})]}),(0,M.jsxs)(W,{id:`searchOnboarding`,type:`button`,onClick:e.onSearch,disabled:e.loading.onboarding,children:[(0,M.jsx)(w,{}),`Search`]})]}),(0,M.jsxs)(`div`,{className:`grid gap-3 border-b bg-background p-4 md:grid-cols-[minmax(140px,.8fr)_minmax(150px,1fr)_minmax(150px,1fr)_minmax(120px,.7fr)_auto]`,children:[(0,M.jsxs)(q,{children:[`Status`,(0,M.jsxs)(J,{id:`onboardingState`,value:e.onboardingState,onChange:t=>e.setOnboardingState(t.target.value),children:[(0,M.jsx)(`option`,{value:``,children:`Any state`}),jn.map(([e,t])=>(0,M.jsx)(`option`,{value:e,children:t},e))]})]}),(0,M.jsxs)(q,{children:[`Onboarder`,(0,M.jsx)(K,{id:`onboarderFilter`,value:e.onboarderFilter,autoComplete:`off`,placeholder:`Any onboarder`,onChange:t=>e.setOnboarderFilter(t.target.value),onKeyDown:t=>t.key===`Enter`&&e.onSearch()})]}),(0,M.jsxs)(q,{children:[`Add filter`,(0,M.jsx)(J,{id:`onboardingFilterKind`,value:e.onboardingFilterKind,disabled:e.onboardingFilterKeys.length===0,onChange:t=>e.setOnboardingFilterKind(t.target.value),children:e.onboardingFilterKeys.map(e=>(0,M.jsx)(`option`,{value:e,children:kn[e].label},e))})]}),(0,M.jsxs)(q,{children:[`Value`,(0,M.jsx)(J,{id:`onboardingFilterValue`,value:e.onboardingFilterValue,onChange:t=>e.setOnboardingFilterValue(t.target.value),children:t.map(([e,t])=>(0,M.jsx)(`option`,{value:e,children:t},e))})]}),(0,M.jsx)(W,{id:`addOnboardingFilter`,type:`button`,onClick:e.addFilter,disabled:e.onboardingFilterKeys.length===0,children:`Add filter`}),(0,M.jsx)(`div`,{id:`activeOnboardingFilters`,className:`md:col-span-5`,children:(0,M.jsx)(Xn,{filters:e.onboardingFilters,onRemove:e.removeFilter,suffix:`onboarding filter`})})]}),(0,M.jsx)(N,{hidden:e.people.length!==0,children:`No prospects match this queue view.`}),(0,M.jsx)(`div`,{className:`overflow-x-auto`,children:(0,M.jsxs)(zt,{id:`onboardingTable`,className:Rt(`min-w-[1340px]`,e.people.length===0&&`hidden`),"aria-label":`Onboarding queue`,children:[(0,M.jsx)(Bt,{children:(0,M.jsxs)(z,{children:[(0,M.jsx)(H,{className:`w-[18%]`,label:`Name`,scope:`onboarding`,sort:e.sort,sortKey:`name`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[12%]`,label:`Status`,scope:`onboarding`,sort:e.sort,sortKey:`onboarding_state`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[20%]`,label:`Onboarder`,scope:`onboarding`,sort:e.sort,sortKey:`onboarder`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[12%]`,label:`Updated`,scope:`onboarding`,sort:e.sort,sortKey:`updated`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(B,{className:`w-[15%]`,children:`Email`}),(0,M.jsx)(B,{className:`w-[12%]`,children:`Links`}),(0,M.jsx)(H,{className:`w-[11%]`,label:`Needs`,scope:`onboarding`,sort:e.sort,sortKey:`profile_gaps`,onSort:(t,n)=>e.onSort(n)})]})}),(0,M.jsx)(R,{id:`onboardingBody`,children:e.people.map(t=>(0,M.jsx)(Ir,{person:t,loading:e.loading,canWrite:e.canWrite,onAssign:e.onAssign,onStatusChange:e.onStatusChange,onDraftEmail:e.onDraftEmail,onSendEmail:e.onSendEmail,crmContactUrl:e.crmContactUrl,crmAttachmentUrl:e.crmAttachmentUrl,canConfigure:e.canConfigure,onOpenConfiguration:e.onOpenConfiguration},t.crm_contact_id||t.latest_intake_submission?.submission_id||t.email||t.name))})]})})]})]})}var Er=[`Female`,`Genderqueer`,`Male`,`Non-Conforming`,`Other`,`Prefer not to say`,`Transgender`],Dr=[`Company Email`,`Personal Email`,`User ID`];function Or(e){let t=(e||``).trim().split(/\s+/).filter(Boolean);return t.length===0?{first:``,middle:``,last:``}:t.length===1?{first:t[0],middle:``,last:``}:t.length===2?{first:t[0],middle:``,last:t[1]}:{first:t[0],middle:t.slice(1,-1).join(` `),last:t[t.length-1]}}function kr(e){let t=(e.email||``).trim();return!t||t.toLowerCase().endsWith(`@508.dev`)?``:t}function Ar(e){let t=(e.email_508||``).trim();if(t)return t;let n=(e.email||``).trim();return n.toLowerCase().endsWith(`@508.dev`)?n:``}function jr(e,t){let n=e?.normalized_payload?.[t];return n==null?``:typeof n==`string`?n.trim():typeof n==`number`||typeof n==`boolean`?String(n):Array.isArray(n)?n.map(e=>String(e)).join(`, `):``}function Mr(e){let t=jr(e,`resume_url`);return/^https:\/\//i.test(t)?t:``}function Nr(e){let t=jr(e,`resume_file_name`);if(t)return t;let n=Mr(e);if(!n)return``;try{let e=new URL(n).pathname;return decodeURIComponent(e.split(`/`).filter(Boolean).pop()||`Resume`)}catch{return`Resume`}}function Pr(e){return e?.normalized_payload?[[`Native name`,jr(e,`native_name`)],[`Weekly hours`,jr(e,`ideal_weekly_hours`)],[`Chat times`,jr(e,`chat_availability`)||jr(e,`availability`)],[`Rate`,jr(e,`rate_range`)],[`Interest`,jr(e,`top_question_about_508`)],[`Skills/interests`,jr(e,`primary_skills_interests`)]].filter(([,e])=>e):[]}function Fr({loading:e,onSetup:t}){let[n,r]=(0,l.useState)(``),[i,a]=(0,l.useState)([]),[o,s]=(0,l.useState)(!1),[c,u]=(0,l.useState)(``),[d,f]=(0,l.useState)(``),[p,m]=(0,l.useState)(``),[h,g]=(0,l.useState)(``),[_,v]=(0,l.useState)(``),[y,b]=(0,l.useState)(``),[x,ee]=(0,l.useState)(``),[te,S]=(0,l.useState)(``),[C,ne]=(0,l.useState)(``),[re,ie]=(0,l.useState)(``),[ae,oe]=(0,l.useState)(``);function T(e){let t=Or(e.name);m(t.first),g(t.middle),v(t.last),f(Ar(e)),ie(kr(e)),b(e.address_country||``),r(e.name||e.email_508||e.email||``),a([]),u(``)}async function E(){let e=n.trim();if(e){s(!0),u(``);try{a(await X(`/dashboard/api/people?${new URLSearchParams({limit:`8`,query:e}).toString()}`))}catch(e){u(In(e,`Unable to search people`)),a([])}finally{s(!1)}}}async function D(){let e={email:d,first_name:p,middle_name:h,last_name:_,country:y,personal_email:re};x.trim()&&(e.gender=x),te.trim()&&(e.date_of_birth=te),C.trim()&&(e.date_of_joining=C),ae.trim()&&(e.prefered_email=ae),await t(e)&&(r(``),a([]),f(``),m(``),g(``),v(``),b(``),ee(``),S(``),ne(``),ie(``),oe(``))}return(0,M.jsxs)(G,{children:[(0,M.jsx)(qt,{children:(0,M.jsx)(Jt,{children:`Engineer setup`})}),(0,M.jsx)(Yt,{children:(0,M.jsxs)(`form`,{className:`grid gap-3`,onSubmit:e=>{e.preventDefault(),D()},children:[(0,M.jsxs)(`div`,{className:`grid gap-3 border-b pb-3 md:grid-cols-[minmax(0,1fr)_auto]`,children:[(0,M.jsxs)(q,{children:[`CRM person`,(0,M.jsx)(K,{value:n,autoComplete:`off`,placeholder:`Search name or email`,onChange:e=>r(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),E())}})]}),(0,M.jsxs)(W,{type:`button`,onClick:E,disabled:o||!n.trim(),children:[(0,M.jsx)(w,{}),`Search`]}),c?(0,M.jsx)(`span`,{className:`text-sm font-semibold text-destructive`,children:c}):null,i.length>0?(0,M.jsx)(`div`,{className:`grid gap-2 md:col-span-2`,children:i.map(e=>{let t=e.name||e.email_508||e.email||e.crm_contact_id,n=[e.email_508||e.email,e.contact_type].filter(Boolean).join(` | `);return(0,M.jsxs)(`button`,{type:`button`,className:`grid rounded-md border bg-background px-3 py-2 text-left text-sm hover:border-primary`,onClick:()=>T(e),children:[(0,M.jsx)(`strong`,{children:t}),n?(0,M.jsx)(`span`,{className:`text-muted-foreground`,children:n}):null]},e.crm_contact_id||t)})}):null]}),(0,M.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(130px,.6fr)]`,children:[(0,M.jsxs)(q,{children:[`Company email`,(0,M.jsx)(K,{value:d,autoComplete:`off`,placeholder:`engineer@508.dev`,onChange:e=>f(e.target.value)})]}),(0,M.jsxs)(q,{children:[`First name`,(0,M.jsx)(K,{value:p,autoComplete:`off`,placeholder:`First`,onChange:e=>m(e.target.value)})]}),(0,M.jsxs)(q,{children:[`Middle name`,(0,M.jsx)(K,{value:h,autoComplete:`off`,placeholder:`Optional`,onChange:e=>g(e.target.value)})]})]}),(0,M.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(130px,.6fr)]`,children:[(0,M.jsxs)(q,{children:[`Last name`,(0,M.jsx)(K,{value:_,autoComplete:`off`,placeholder:`Last`,onChange:e=>v(e.target.value)})]}),(0,M.jsxs)(q,{children:[`Country`,(0,M.jsx)(K,{value:y,autoComplete:`off`,placeholder:`Taiwan`,onChange:e=>b(e.target.value)})]})]}),(0,M.jsxs)(`details`,{className:`rounded-md border bg-background p-3`,children:[(0,M.jsx)(`summary`,{className:`cursor-pointer text-sm font-extrabold`,children:`Advanced options`}),(0,M.jsxs)(`div`,{className:`mt-3 grid gap-3 md:grid-cols-2`,children:[(0,M.jsxs)(q,{children:[`Gender`,(0,M.jsxs)(J,{value:x,onChange:e=>ee(e.target.value),children:[(0,M.jsx)(`option`,{value:``,children:`Default`}),Er.map(e=>(0,M.jsx)(`option`,{value:e,children:e},e))]})]}),(0,M.jsxs)(q,{children:[`Date of birth`,(0,M.jsx)(K,{value:te,type:`date`,autoComplete:`off`,onChange:e=>S(e.target.value)})]}),(0,M.jsxs)(q,{children:[`Date of joining`,(0,M.jsx)(K,{value:C,type:`date`,autoComplete:`off`,onChange:e=>ne(e.target.value)})]}),(0,M.jsxs)(q,{children:[`Personal email`,(0,M.jsx)(K,{value:re,type:`email`,autoComplete:`off`,placeholder:`Optional`,onChange:e=>ie(e.target.value)})]}),(0,M.jsxs)(q,{children:[`Preferred contact email`,(0,M.jsxs)(J,{value:ae,onChange:e=>oe(e.target.value),children:[(0,M.jsx)(`option`,{value:``,children:`Default`}),Dr.map(e=>(0,M.jsx)(`option`,{value:e,children:e},e))]})]})]})]}),(0,M.jsx)(`div`,{className:`flex flex-wrap items-center justify-between gap-3`,children:(0,M.jsxs)(W,{id:`setupEngineer`,type:`submit`,disabled:e||!d.trim()||!p.trim(),children:[(0,M.jsx)(se,{}),`Set up engineer`]})})]})})]})}function Ir({person:e,loading:t,canWrite:n,onAssign:r,onStatusChange:i,onDraftEmail:a,onSendEmail:o,crmContactUrl:s,crmAttachmentUrl:c,canConfigure:u,onOpenConfiguration:d}){let f=e.name||e.email_508||e.email||`CRM contact`,[p,m]=(0,l.useState)(an(e.onboarder)),[h,g]=(0,l.useState)(!1),[_,v]=(0,l.useState)(null),[y,b]=(0,l.useState)(null),[x,ee]=(0,l.useState)({has_contributed:Nn(tn(e))===`onboarded`,discord_joined:e.discord_user_id?`yes`:`unknown`,agreement_signed:`unknown`});(0,l.useEffect)(()=>m(an(e.onboarder)),[e.onboarder]);let S=Nn(tn(e)),w=!!e.crm_contact_id,ie=e.profile_status||{},ae=[[`Discord`,ie.discord_linked],[`Resume`,ie.latest_resume],[`Skills`,Number(ie.skills_count||0)>0]].filter(([,e])=>!e),oe=s(e.crm_contact_id),se=c(e.latest_resume_id),T=e.latest_intake_submission,E=e.latest_resume_intake_submission||T,D=Mr(E),O=se||D,ce=se?`Resume`:Nr(E)||e.latest_resume_name||`Resume`,le=Pr(T),ue=_?.onboarding_email_sent_at||e.onboarding_email_sent_at,de=_?.onboarding_email_sent_by||e.onboarding_email_sent_by,k=_?.onboarding_email_recipient||e.onboarding_email_recipient,A=!_||y!==null&&y.has_contributed===x.has_contributed&&y.discord_joined===x.discord_joined&&y.agreement_signed===x.agreement_signed,j=_&&!_.onboarding_email_sent_at?A?_.can_send?``:_.recipient_email?_.reply_to_email?`Send disabled: onboarding email SMTP is not configured.`:`Send disabled: your Reply-To email is missing.`:`Send disabled: candidate email is missing.`:`Send disabled: regenerate after changing draft options.`:``,N=j.includes(`SMTP`),fe=!!t[`onboarding-email-draft:${e.crm_contact_id}`],pe=!!t[`onboarding-email-send:${e.crm_contact_id}`],me=_?.markdown_body||``;async function he(t=x){let n=await a(e.crm_contact_id,t);n&&(v(n),b({...t}),g(!0))}async function ge(){if(!_||!y||!A)return;let t=await o(e.crm_contact_id,y,_.markdown_body);t&&v(t)}return(0,M.jsxs)(M.Fragment,{children:[(0,M.jsxs)(z,{children:[(0,M.jsxs)(V,{children:[oe?(0,M.jsx)(`a`,{className:`font-extrabold text-primary`,href:oe,target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${f} in CRM`,children:f}):(0,M.jsx)(`strong`,{children:f}),(0,M.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:e.email_508||e.email||``}),w?null:(0,M.jsx)(`div`,{className:`mt-1`,children:(0,M.jsx)(U,{variant:`missing`,children:`Application only`})}),T?(0,M.jsxs)(`details`,{className:`mt-2 rounded-md border bg-secondary/30 px-2 py-1 text-xs`,children:[(0,M.jsxs)(`summary`,{className:`cursor-pointer font-extrabold`,children:[`Application`,T.source?` via ${T.source}`:``,T.submitted_at?` | ${Y(T.submitted_at)}`:``]}),(0,M.jsxs)(`div`,{className:`mt-2 grid gap-1 text-muted-foreground`,children:[le.length>0?le.map(([e,t])=>(0,M.jsxs)(`span`,{children:[(0,M.jsxs)(`strong`,{className:`text-foreground`,children:[e,`:`]}),` `,t]},e)):(0,M.jsx)(`span`,{children:`No extra application fields.`}),T.submission_id?(0,M.jsxs)(`span`,{children:[`Submission `,T.submission_id]}):null]})]}):null]}),(0,M.jsx)(V,{children:(0,M.jsxs)(`div`,{className:`grid max-w-56 gap-2`,children:[(0,M.jsx)(U,{variant:rn(tn(e)),children:e.onboarding_status_label||nn(tn(e))}),n&&w?(0,M.jsxs)(J,{"aria-label":`Onboarding status for ${f}`,value:S,disabled:t[`onboarding-status:${e.crm_contact_id}`],onChange:t=>i(e.crm_contact_id,t.target.value),children:[S?null:(0,M.jsx)(`option`,{value:``,disabled:!0,children:`No status`}),An.map(([e,t])=>(0,M.jsx)(`option`,{value:e,children:t},e))]}):null]})}),(0,M.jsx)(V,{children:(0,M.jsxs)(`form`,{className:`grid max-w-64 grid-cols-[minmax(100px,1fr)_auto] items-center gap-2`,onSubmit:t=>{t.preventDefault(),r(e.crm_contact_id,p)},children:[(0,M.jsx)(K,{"aria-label":`Onboarder for ${f}`,value:p,placeholder:`508 username`,disabled:!w,onChange:e=>m(e.target.value)}),(0,M.jsx)(W,{type:`submit`,size:`sm`,"aria-label":`Save onboarder for ${f}`,disabled:!w||t[`onboarder:${e.crm_contact_id}`],children:`Save`})]})}),(0,M.jsx)(V,{children:Y(e.onboarding_updated_at)}),(0,M.jsx)(V,{children:(0,M.jsxs)(`div`,{className:`grid gap-2`,children:[ue?(0,M.jsxs)(U,{variant:`succeeded`,children:[`Sent `,Y(ue)]}):(0,M.jsx)(U,{variant:`neutral`,children:`Not sent`}),k?(0,M.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:k}):null,de?(0,M.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[`By `,de]}):null,n&&w?(0,M.jsxs)(W,{type:`button`,size:`sm`,variant:h?`outline`:`secondary`,onClick:()=>{if(h){g(!1);return}g(!0),_||he()},disabled:fe,children:[(0,M.jsx)(te,{}),_?`Edit draft`:`Draft email`]}):null]})}),(0,M.jsx)(V,{children:(0,M.jsxs)(`div`,{className:`flex flex-wrap gap-1.5`,children:[O?(0,M.jsx)(`a`,{className:`inline-flex min-h-7 max-w-40 items-center truncate rounded-md border bg-secondary px-2 text-xs font-extrabold`,href:O,target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${f} resume`,title:ce,children:ce}):null,un(e.linkedin)?(0,M.jsx)(`a`,{className:`inline-flex min-h-7 items-center rounded-md border bg-secondary px-2 text-xs font-extrabold`,href:un(e.linkedin),target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${f} LinkedIn`,children:`LinkedIn`}):null,dn(e.github_username)?(0,M.jsx)(`a`,{className:`inline-flex min-h-7 items-center rounded-md border bg-secondary px-2 text-xs font-extrabold`,href:dn(e.github_username),target:`_blank`,rel:`noreferrer`,"aria-label":`Open ${f} GitHub`,children:e.github_username||`GitHub`}):null,!O&&!un(e.linkedin)&&!dn(e.github_username)?`None`:null]})}),(0,M.jsx)(V,{children:(0,M.jsxs)(`div`,{className:`flex flex-wrap gap-1.5`,children:[ae.map(([e])=>(0,M.jsxs)(U,{variant:`missing`,children:[`Missing `,e]},String(e))),ae.length===0?`None`:null]})})]}),h?(0,M.jsx)(z,{children:(0,M.jsx)(V,{colSpan:7,className:`bg-secondary/30`,children:(0,M.jsxs)(`div`,{className:`grid gap-3 rounded-md border bg-background p-4`,children:[(0,M.jsxs)(`div`,{className:`grid gap-3 md:grid-cols-[auto_minmax(150px,220px)_minmax(150px,220px)_auto] md:items-end`,children:[(0,M.jsxs)(`label`,{className:`flex min-h-9 items-center gap-2 text-sm font-semibold`,children:[(0,M.jsx)(`input`,{type:`checkbox`,checked:x.has_contributed,onChange:e=>{ee({...x,has_contributed:e.target.checked})}}),`Contribution done`]}),(0,M.jsxs)(q,{children:[`Discord`,(0,M.jsxs)(J,{value:x.discord_joined,onChange:e=>ee({...x,discord_joined:e.target.value}),children:[(0,M.jsx)(`option`,{value:`unknown`,children:`Unknown`}),(0,M.jsx)(`option`,{value:`yes`,children:`Joined`}),(0,M.jsx)(`option`,{value:`no`,children:`Not joined`})]})]}),(0,M.jsxs)(q,{children:[`Agreement`,(0,M.jsxs)(J,{value:x.agreement_signed,onChange:e=>ee({...x,agreement_signed:e.target.value}),children:[(0,M.jsx)(`option`,{value:`unknown`,children:`Unknown`}),(0,M.jsx)(`option`,{value:`yes`,children:`Signed`}),(0,M.jsx)(`option`,{value:`no`,children:`Not signed`})]})]}),(0,M.jsxs)(W,{type:`button`,variant:`outline`,onClick:()=>he(),disabled:fe,children:[(0,M.jsx)(C,{}),`Regenerate`]})]}),_?(0,M.jsxs)(M.Fragment,{children:[(0,M.jsxs)(`div`,{className:`grid gap-2 text-sm md:grid-cols-4`,children:[(0,M.jsxs)(`span`,{children:[(0,M.jsx)(`strong`,{children:`To:`}),` `,_.recipient_email||`Missing`]}),(0,M.jsxs)(`span`,{children:[(0,M.jsx)(`strong`,{children:`Reply-To:`}),` `,_.reply_to_email||`Missing`]}),(0,M.jsxs)(`span`,{children:[(0,M.jsx)(`strong`,{children:`Cc:`}),` `,_.cc_email||`Missing`]}),(0,M.jsxs)(`span`,{children:[(0,M.jsx)(`strong`,{children:`From:`}),` `,_.sender_display_name||`onboarding`]})]}),(0,M.jsxs)(q,{children:[`Subject`,(0,M.jsx)(K,{value:_.subject,readOnly:!0})]}),(0,M.jsxs)(q,{children:[`Draft`,(0,M.jsx)(`textarea`,{value:me,className:`min-h-64 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]`,onChange:e=>v({..._,markdown_body:e.target.value})})]}),(0,M.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2`,children:[(0,M.jsxs)(W,{type:`button`,variant:`default`,onClick:ge,disabled:pe||!_.can_send||!A||!me.trim(),title:j||void 0,children:[(0,M.jsx)(ne,{}),pe?`Sending`:`Send`]}),j?(0,M.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[j,N&&u?(0,M.jsxs)(W,{type:`button`,size:`sm`,variant:`ghost`,className:`ml-2`,onClick:d,children:[(0,M.jsx)(re,{}),`Configure`]}):null]}):null,_.marker_status===`error`?(0,M.jsxs)(`span`,{className:`text-sm text-muted-foreground`,children:[`Marker not saved: `,_.marker_error||`unknown`]}):null]})]}):(0,M.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:fe?`Drafting email`:`No draft loaded`})]})})}):null]})}function Lr(e){return(0,M.jsxs)(M.Fragment,{children:[(0,M.jsxs)(G,{className:`grid gap-3 p-4 md:grid-cols-4 md:items-end`,children:[(0,M.jsxs)(q,{children:[`Window`,(0,M.jsxs)(J,{id:`minutes`,value:e.minutes,onChange:t=>e.setMinutes(t.target.value),children:[(0,M.jsx)(`option`,{value:`15`,children:`15 minutes`}),(0,M.jsx)(`option`,{value:`60`,children:`1 hour`}),(0,M.jsx)(`option`,{value:`360`,children:`6 hours`}),(0,M.jsx)(`option`,{value:`1440`,children:`24 hours`})]})]}),(0,M.jsxs)(q,{children:[`Status`,(0,M.jsxs)(J,{id:`status`,value:e.status,onChange:t=>e.setStatus(t.target.value),children:[(0,M.jsx)(`option`,{value:``,children:`Any status`}),(0,M.jsx)(`option`,{value:`queued`,children:`Queued`}),(0,M.jsx)(`option`,{value:`running`,children:`Running`}),(0,M.jsx)(`option`,{value:`succeeded`,children:`Succeeded`}),(0,M.jsx)(`option`,{value:`failed`,children:`Failed`}),(0,M.jsx)(`option`,{value:`dead`,children:`Dead`}),(0,M.jsx)(`option`,{value:`canceled`,children:`Canceled`})]})]}),(0,M.jsxs)(q,{children:[`Type`,(0,M.jsx)(K,{id:`jobType`,value:e.jobType,autoComplete:`off`,placeholder:`Any type`,onChange:t=>e.setJobType(t.target.value),onKeyDown:t=>t.key===`Enter`&&e.onSearch()})]}),(0,M.jsxs)(W,{id:`refreshJobs`,type:`button`,onClick:e.onSearch,disabled:e.loading.jobs,children:[(0,M.jsx)(C,{}),`Refresh background tasks`]})]}),(0,M.jsxs)(`section`,{className:`grid gap-3 md:grid-cols-4`,"aria-label":`Background task summary`,children:[(0,M.jsx)(Wn,{id:`metricTotal`,label:`Total`,value:e.jobs.length}),(0,M.jsx)(Wn,{id:`metricQueued`,label:`Queued`,value:e.jobCounts.queued||0}),(0,M.jsx)(Wn,{id:`metricRunning`,label:`Running`,value:e.jobCounts.running||0}),(0,M.jsx)(Wn,{id:`metricFailed`,label:`Failed`,value:(e.jobCounts.failed||0)+(e.jobCounts.dead||0)})]}),(0,M.jsxs)(G,{children:[(0,M.jsx)(qt,{children:(0,M.jsx)(Jt,{children:`Recent background tasks`})}),(0,M.jsx)(N,{hidden:e.jobs.length!==0,children:`No background tasks match these filters.`}),(0,M.jsx)(`div`,{className:`overflow-x-auto`,children:(0,M.jsxs)(zt,{id:`jobsTable`,className:Rt(`min-w-[980px]`,e.jobs.length===0&&`hidden`),"aria-label":`Recent background tasks`,children:[(0,M.jsx)(Bt,{children:(0,M.jsxs)(z,{children:[(0,M.jsx)(H,{className:`w-[22%]`,label:`Task id`,scope:`jobs`,sort:e.sort,sortKey:`job_id`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[24%]`,label:`Task type`,scope:`jobs`,sort:e.sort,sortKey:`type`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[12%]`,label:`Status`,scope:`jobs`,sort:e.sort,sortKey:`status`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[12%]`,label:`Attempts`,scope:`jobs`,sort:e.sort,sortKey:`attempts`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[18%]`,label:`Updated`,scope:`jobs`,sort:e.sort,sortKey:`updated_at`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(B,{children:`Actions`})]})}),(0,M.jsx)(R,{id:`jobsBody`,children:e.jobs.map(t=>(0,M.jsxs)(z,{children:[(0,M.jsx)(V,{className:`font-mono`,children:t.job_id}),(0,M.jsx)(V,{children:t.type}),(0,M.jsx)(V,{children:(0,M.jsx)(U,{variant:t.status||`neutral`,children:t.status})}),(0,M.jsxs)(V,{children:[t.attempts,`/`,t.max_attempts]}),(0,M.jsx)(V,{children:Y(t.updated_at)}),(0,M.jsx)(V,{children:(0,M.jsxs)(`div`,{className:`flex flex-wrap justify-end gap-2`,children:[(0,M.jsx)(W,{type:`button`,size:`sm`,variant:`outline`,"aria-label":`View details for ${t.type} task ${t.job_id}`,onClick:()=>e.onDetail(t.job_id),disabled:e.loading[`detail:${t.job_id}`],children:`Details`}),e.canWrite?(0,M.jsx)(W,{type:`button`,size:`sm`,"aria-label":`Rerun ${t.type} task ${t.job_id}`,onClick:()=>e.onRerun(t.job_id),disabled:e.loading[`rerun:${t.job_id}`],children:`Rerun`}):null]})})]},t.job_id))})]})})]}),e.jobDetail?(0,M.jsxs)(G,{id:`jobDetailPanel`,children:[(0,M.jsxs)(qt,{children:[(0,M.jsx)(Jt,{children:`Task detail`}),(0,M.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:e.jobDetail.job_id})]}),(0,M.jsxs)(Yt,{className:`grid gap-4`,children:[(0,M.jsx)(`div`,{className:`grid gap-3 md:grid-cols-2`,children:[[`Task type`,e.jobDetail.type],[`Status`,e.jobDetail.status],[`Attempts`,`${e.jobDetail.attempts}/${e.jobDetail.max_attempts}`],[`Updated`,Y(e.jobDetail.updated_at)],[`Created`,Y(e.jobDetail.created_at)],[`Run after`,Y(e.jobDetail.run_after)],[`Locked by`,e.jobDetail.locked_by||`None`],[`Last error`,e.jobDetail.last_error||`None`]].map(([e,t])=>(0,M.jsxs)(`div`,{className:`grid gap-1 rounded-md border bg-background p-3`,children:[(0,M.jsx)(`span`,{className:`text-[11px] font-extrabold uppercase text-muted-foreground`,children:e}),(0,M.jsx)(`strong`,{className:`break-words text-sm`,children:t})]},e))}),(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`h2`,{className:`mb-2 text-[15px] font-bold`,children:`Payload`}),(0,M.jsx)(`pre`,{className:`max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md border bg-background p-3 font-mono text-xs`,children:Qt(e.jobDetail.payload)||`No payload`})]}),(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`h2`,{className:`mb-2 text-[15px] font-bold`,children:`Result`}),(0,M.jsx)(`pre`,{className:`max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md border bg-background p-3 font-mono text-xs`,children:Qt(e.jobDetail.result)||`No result`})]})]})]}):null]})}function Rr(e){return(0,M.jsxs)(G,{children:[(0,M.jsxs)(qt,{children:[(0,M.jsx)(Jt,{children:`Recent audit`}),(0,M.jsxs)(W,{id:`refreshAudit`,type:`button`,variant:`outline`,onClick:e.onRefresh,disabled:e.loading.audit,children:[(0,M.jsx)(C,{}),`Refresh`]})]}),(0,M.jsx)(N,{hidden:e.events.length!==0,children:`No audit events found.`}),(0,M.jsx)(`div`,{className:`overflow-x-auto`,children:(0,M.jsxs)(zt,{id:`auditTable`,className:Rt(`min-w-[760px]`,e.events.length===0&&`hidden`),"aria-label":`Recent audit events`,children:[(0,M.jsx)(Bt,{children:(0,M.jsxs)(z,{children:[(0,M.jsx)(H,{className:`w-[24%]`,label:`Time`,scope:`audit`,sort:e.sort,sortKey:`occurred_at`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[28%]`,label:`Actor`,scope:`audit`,sort:e.sort,sortKey:`actor`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[28%]`,label:`Action`,scope:`audit`,sort:e.sort,sortKey:`action`,onSort:(t,n)=>e.onSort(n)}),(0,M.jsx)(H,{className:`w-[20%]`,label:`Result`,scope:`audit`,sort:e.sort,sortKey:`result`,onSort:(t,n)=>e.onSort(n)})]})}),(0,M.jsx)(R,{id:`auditBody`,children:e.events.map(e=>(0,M.jsxs)(z,{children:[(0,M.jsx)(V,{children:Y(e.occurred_at)}),(0,M.jsx)(V,{children:e.actor_display_name||e.actor_subject||e.actor_provider}),(0,M.jsx)(V,{children:e.action}),(0,M.jsx)(V,{children:(0,M.jsx)(U,{variant:e.result===`success`?`succeeded`:`failed`,children:e.result})})]},e.id||`${e.occurred_at||``}-${e.actor_subject||``}-${e.action||``}`))})]})})]})}function zr({report:e,loading:t,onRefresh:n}){let r=e?.summary||{},i=[[`Status`,e?.status_counts||{}],[`Intent`,e?.intent_counts||{}],[`Planner`,e?.planner_counts||{}],[`Model`,e?.model_counts||{}],[`Action`,e?.action_counts||{}],[`Tool outcome`,e?.tool_outcome_counts||{}]].flatMap(([e,t])=>Object.entries(t).map(([t,n])=>({label:e,value:t,count:n}))).sort((e,t)=>t.count-e.count||e.label.localeCompare(t.label)),a=Array.isArray(e?.recent_unsupported)?e.recent_unsupported:[];return(0,M.jsxs)(M.Fragment,{children:[(0,M.jsxs)(G,{children:[(0,M.jsxs)(qt,{children:[(0,M.jsx)(Jt,{children:`Agent requests`}),(0,M.jsxs)(W,{id:`refreshAgent`,type:`button`,variant:`outline`,onClick:n,disabled:t.agent,children:[(0,M.jsx)(C,{}),`Refresh`]})]}),(0,M.jsxs)(Yt,{className:`grid gap-3 md:grid-cols-5`,children:[(0,M.jsx)(Wn,{id:`agentMetricTotal`,label:`Total`,value:r.total||0}),(0,M.jsx)(Wn,{id:`agentMetricHandled`,label:`Handled`,value:r.handled||0}),(0,M.jsx)(Wn,{id:`agentMetricConfirmations`,label:`Confirmations`,value:r.requires_confirmation||0}),(0,M.jsx)(Wn,{id:`agentMetricClarifications`,label:`Clarifications`,value:r.needs_clarification||0}),(0,M.jsx)(Wn,{id:`agentMetricUnsupported`,label:`Not understood`,value:r.unsupported||0})]})]}),(0,M.jsxs)(G,{children:[(0,M.jsxs)(qt,{children:[(0,M.jsx)(Jt,{children:`Request mix`}),(0,M.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:`Recent agent.request audit events.`})]}),(0,M.jsx)(N,{hidden:i.length!==0,children:`No agent request data found.`}),(0,M.jsx)(`div`,{className:`overflow-x-auto`,children:(0,M.jsxs)(zt,{id:`agentBreakdownTable`,className:Rt(`min-w-[860px]`,i.length===0&&`hidden`),"aria-label":`Agent request breakdown`,children:[(0,M.jsx)(Bt,{children:(0,M.jsxs)(z,{children:[(0,M.jsx)(B,{children:`Dimension`}),(0,M.jsx)(B,{children:`Value`}),(0,M.jsx)(B,{children:`Count`})]})}),(0,M.jsx)(R,{id:`agentBreakdownBody`,children:i.map(e=>(0,M.jsxs)(z,{children:[(0,M.jsx)(V,{children:e.label}),(0,M.jsx)(V,{children:e.value}),(0,M.jsx)(V,{children:e.count})]},`${e.label}-${e.value}`))})]})})]}),(0,M.jsxs)(G,{children:[(0,M.jsxs)(qt,{children:[(0,M.jsx)(Jt,{children:`Not understood`}),(0,M.jsx)(`span`,{className:`text-sm text-muted-foreground`,children:`Sanitized request text only.`})]}),(0,M.jsx)(N,{hidden:a.length!==0,children:`No unsupported agent requests found.`}),(0,M.jsx)(`div`,{className:`overflow-x-auto`,children:(0,M.jsxs)(zt,{id:`agentUnsupportedTable`,className:Rt(`min-w-[860px]`,a.length===0&&`hidden`),"aria-label":`Unsupported agent requests`,children:[(0,M.jsx)(Bt,{children:(0,M.jsxs)(z,{children:[(0,M.jsx)(B,{children:`Time`}),(0,M.jsx)(B,{children:`Actor`}),(0,M.jsx)(B,{children:`Message`}),(0,M.jsx)(B,{children:`Result`})]})}),(0,M.jsx)(R,{id:`agentUnsupportedBody`,children:a.map(e=>(0,M.jsxs)(z,{children:[(0,M.jsx)(V,{children:Y(e.occurred_at)}),(0,M.jsx)(V,{children:e.actor}),(0,M.jsx)(V,{children:e.message_sanitized}),(0,M.jsx)(V,{children:(0,M.jsx)(U,{variant:e.result===`success`?`succeeded`:`failed`,children:e.result||`unknown`})})]},`${e.occurred_at||``}-${e.actor||``}-${e.message_sanitized||``}`))})]})})]})]})}var Br=document.getElementById(`root`);if(Br)(0,j.createRoot)(Br).render((0,M.jsx)(l.StrictMode,{children:(0,M.jsx)(Kn,{})}));else throw Error(`Missing #root container`); \ No newline at end of file diff --git a/apps/api/src/five08/backend/static/dashboard/index.html b/apps/api/src/five08/backend/static/dashboard/index.html index 569ab520..0ec01f27 100644 --- a/apps/api/src/five08/backend/static/dashboard/index.html +++ b/apps/api/src/five08/backend/static/dashboard/index.html @@ -4,8 +4,8 @@ 508 Operations Dashboard - - + +
diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/projects.py b/apps/discord_bot/src/five08/discord_bot/cogs/projects.py index 81397b7b..e2725cb2 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/projects.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/projects.py @@ -3,7 +3,10 @@ from __future__ import annotations import asyncio +import hashlib import logging +from collections.abc import Sequence +from decimal import Decimal, InvalidOperation from typing import Any import discord @@ -11,17 +14,34 @@ from discord.ext import commands from five08.discord_bot.config import settings -from five08.discord_bot.utils.role_decorators import check_user_roles_with_hierarchy +from five08.discord_bot.utils.audit import DiscordAuditCogMixin +from five08.discord_bot.utils.role_decorators import ( + check_user_roles_with_hierarchy, + require_role, +) +from five08.project_discord_channels import ( + ProjectDiscordChannelConflict, + ProjectPaymentDiscordDeliveryClaimStatus, + claim_project_payment_discord_delivery, + mark_project_payment_discord_delivery_failed, + mark_project_payment_discord_delivery_sent, + record_project_discord_channel_verification, + register_project_discord_channel, + renew_project_payment_discord_delivery_lease, + unregister_project_discord_channel, +) +from five08.project_payments import get_project_payment_notification_delivery_context from five08.projects import list_dashboard_projects, project_viewer_emails_for_discord logger = logging.getLogger(__name__) -class ProjectsCog(commands.Cog, name="Projects"): +class ProjectsCog(DiscordAuditCogMixin, commands.Cog, name="Projects"): """Read-only ERP project status views.""" def __init__(self, bot: commands.Bot) -> None: self.bot = bot + self._init_audit_logger() @app_commands.command( name="projects", @@ -139,6 +159,719 @@ def _load_projects( ) return projects, include_all, True + @staticmethod + def _project_choice_name(project: dict[str, Any]) -> str: + """Build a bounded autocomplete label without leaking long ERP fields.""" + display_name = str( + project.get("display_name") + or project.get("erpnext_project_id") + or "Project" + ).strip() + customer = str(project.get("customer") or "").strip() + label = f"{display_name} — {customer}" if customer else display_name + return label[:100] + + def _is_configured_guild(self, guild_id: str | int) -> bool: + """Fail closed unless this is the bot's configured/only guild. + + Discord role names are guild-local. A role named ``Steering Committee`` + in another guild must never authorize access to ERP project payments. + """ + configured_guild_id = str(settings.discord_server_id or "").strip() + if configured_guild_id: + return str(guild_id) == configured_guild_id + guilds = getattr(self.bot, "guilds", None) + # discord.py exposes ``Client.guilds`` as a SequenceProxy, not a + # built-in list/tuple. Treat any non-string sequence as the bot's + # live guild collection while still failing closed for mocks/unknown + # values that cannot establish a single authoritative guild. + if ( + not isinstance(guilds, Sequence) + or isinstance(guilds, (str, bytes)) + or len(guilds) != 1 + ): + return False + return str(getattr(guilds[0], "id", "")) == str(guild_id) + + async def project_channel_autocomplete( + self, + interaction: discord.Interaction, + current: str, + ) -> list[app_commands.Choice[str]]: + """Suggest open local-project IDs to authorized channel registrars only.""" + roles = getattr(getattr(interaction, "user", None), "roles", []) + guild = getattr(interaction, "guild", None) + if ( + guild is None + or not self._is_configured_guild(guild.id) + or not check_user_roles_with_hierarchy(roles, ["Steering Committee"]) + ): + # Discord invokes autocomplete before the command callback and may + # not run the command's ``require_role`` check. Do not expose + # private project/customer names to an unauthorized member here. + return [] + try: + projects = await asyncio.to_thread( + list_dashboard_projects, + settings, + query=current or None, + status="Open", + include_all=True, + include_roster=False, + limit=25, + ) + except Exception: + logger.warning("Project-channel autocomplete lookup failed", exc_info=True) + return [] + return [ + app_commands.Choice( + name=self._project_choice_name(project), + value=str(project["id"]), + ) + for project in projects + if project.get("id") + ][:25] + + @staticmethod + def _target_text_channel( + interaction: discord.Interaction, + channel: discord.TextChannel | None, + ) -> discord.TextChannel | None: + """Resolve an explicit target or the current text channel only.""" + candidate = channel or interaction.channel + return candidate if isinstance(candidate, discord.TextChannel) else None + + @staticmethod + def _private_channel_error( + guild: discord.Guild, + channel: discord.TextChannel, + ) -> str | None: + """Revalidate privacy and live bot permissions before storing/sending.""" + if channel.guild.id != guild.id: + return "channel_wrong_guild" + if channel.permissions_for(guild.default_role).view_channel: + return "channel_is_public" + bot_member = guild.me + if bot_member is None: + return "bot_member_unresolved" + permissions = channel.permissions_for(bot_member) + if not permissions.view_channel: + return "missing_view_channel_permission" + if not permissions.send_messages: + return "missing_send_messages_permission" + if not permissions.read_message_history: + return "missing_read_message_history_permission" + return None + + @staticmethod + def _payment_message_content( + *, + notification_id: str, + amount: str, + currency: str | None, + posted_at: str | None, + ) -> str: + """Render a minimal private-channel receipt without payment-party data.""" + try: + normalized_amount = Decimal(amount) + amount_text = format(normalized_amount, ",.2f") + except (InvalidOperation, ValueError): + amount_text = amount.strip() or "an unspecified amount" + money = f"{currency} {amount_text}" if currency else amount_text + lines = [f"✅ Payment received for this project: **{money}**."] + if posted_at: + lines.append(f"Posted: {posted_at}") + # A durable marker lets a retry find a message accepted by Discord when + # the worker timed out before it could store the returned message ID. + lines.append(f"") + return "\n".join(lines) + + async def _resolve_text_channel( + self, + *, + channel_id: str, + ) -> tuple[discord.TextChannel | None, bool]: + """Resolve a text channel and distinguish retryable Discord failures.""" + try: + numeric_channel_id = int(channel_id) + except ValueError: + return None, False + channel = self.bot.get_channel(numeric_channel_id) + if channel is None: + try: + channel = await self.bot.fetch_channel(numeric_channel_id) + except (discord.NotFound, discord.Forbidden): + return None, False + except discord.HTTPException: + logger.warning( + "Failed fetching project payment channel id=%s", + channel_id, + exc_info=True, + ) + return None, True + return ( + (channel, False) + if isinstance(channel, discord.TextChannel) + else (None, False) + ) + + async def _record_project_channel_verification( + self, + *, + mapping_id: str, + error: str | None = None, + ) -> None: + """Best-effort verification telemetry must not mask delivery outcomes.""" + try: + await asyncio.to_thread( + record_project_discord_channel_verification, + settings, + mapping_id=mapping_id, + error=error, + ) + except Exception: + logger.warning( + "Failed recording project payment channel verification mapping_id=%s", + mapping_id, + exc_info=True, + ) + + async def _fail_project_payment_delivery( + self, + *, + notification_id: str, + project_discord_channel_id: str, + lease_token: str, + error: str, + ) -> bool: + """Release an owned bot delivery lease before a retryable/permanent reply.""" + try: + return await asyncio.to_thread( + mark_project_payment_discord_delivery_failed, + settings, + notification_id=notification_id, + project_discord_channel_id=project_discord_channel_id, + error=error, + lease_token=lease_token, + ) + except Exception: + logger.warning( + "Failed releasing project payment delivery lease notification_id=%s", + notification_id, + exc_info=True, + ) + return False + + async def post_project_payment_notification( + self, + *, + notification_id: str, + worker_lease_token: str, + ) -> tuple[dict[str, Any], int]: + """Deliver one canonical notification after live eligibility checks. + + The worker intentionally supplies only the durable outbox ID. Every + money, project, and Discord-channel value is loaded from Postgres so a + caller of this internal endpoint cannot forge a payment announcement. + """ + try: + context = await asyncio.to_thread( + get_project_payment_notification_delivery_context, + settings, + notification_id=notification_id, + worker_lease_token=worker_lease_token, + ) + except Exception: + logger.warning( + "Failed loading canonical project payment notification id=%s", + notification_id, + exc_info=True, + ) + return {"error": "payment_notification_context_unavailable"}, 503 + if context is None: + return {"error": "payment_notification_not_eligible"}, 409 + + try: + guild_id = int(context.guild_id) + except ValueError: + await self._record_project_channel_verification( + mapping_id=context.project_discord_channel_id, + error="invalid_registered_guild_id", + ) + return {"error": "invalid_registered_guild_id"}, 409 + if not self._is_configured_guild(guild_id): + await self._record_project_channel_verification( + mapping_id=context.project_discord_channel_id, + error="payment_channel_wrong_guild", + ) + return {"error": "payment_channel_wrong_guild"}, 403 + guild = self.bot.get_guild(guild_id) + if guild is None: + await self._record_project_channel_verification( + mapping_id=context.project_discord_channel_id, + error="guild_not_found", + ) + # The bot cache can be empty during startup/reconnect, so let the + # worker retry rather than dead-lettering a valid mapping. + return {"error": "guild_not_ready"}, 503 + channel, channel_lookup_retryable = await self._resolve_text_channel( + channel_id=context.channel_id + ) + if channel is None: + error = ( + "channel_lookup_failed" + if channel_lookup_retryable + else "channel_not_found_or_not_text" + ) + await self._record_project_channel_verification( + mapping_id=context.project_discord_channel_id, + error=error, + ) + return {"error": error}, 503 if channel_lookup_retryable else 404 + channel_error = self._private_channel_error(guild, channel) + if channel_error is not None: + await self._record_project_channel_verification( + mapping_id=context.project_discord_channel_id, + error=channel_error, + ) + if channel_error == "bot_member_unresolved": + return {"error": channel_error}, 503 + return {"error": channel_error}, 403 + + claimed_mapping_id = context.project_discord_channel_id + try: + delivery_claim = await asyncio.to_thread( + claim_project_payment_discord_delivery, + settings, + notification_id=notification_id, + worker_lease_token=worker_lease_token, + project_discord_channel_id=claimed_mapping_id, + ) + except Exception: + logger.warning( + "Failed claiming project payment Discord delivery id=%s", + notification_id, + exc_info=True, + ) + return {"error": "payment_notification_delivery_claim_unavailable"}, 503 + if ( + delivery_claim.status + is ProjectPaymentDiscordDeliveryClaimStatus.ALREADY_DELIVERED + ): + await self._record_project_channel_verification( + mapping_id=context.project_discord_channel_id + ) + return { + "status": "already_delivered", + "message_id": delivery_claim.discord_message_id, + }, 200 + if ( + delivery_claim.status + is ProjectPaymentDiscordDeliveryClaimStatus.SCOPE_MISMATCH + ): + return {"error": "payment_notification_not_eligible"}, 409 + if ( + delivery_claim.status + is not ProjectPaymentDiscordDeliveryClaimStatus.CLAIMED + ): + return {"error": "payment_notification_delivery_in_progress"}, 503 + lease_token = str(delivery_claim.lease_token or "").strip() + if not lease_token: + logger.error( + "Project payment delivery claim missing lease token notification_id=%s", + notification_id, + ) + return {"error": "payment_notification_delivery_lease_missing"}, 503 + + # The mapping/project can be revoked after the first read or even while + # the receipt claim is in flight. Re-read the DB-authorized context + # before any Discord side effect, bound to the exact mapping we claimed. + try: + context = await asyncio.to_thread( + get_project_payment_notification_delivery_context, + settings, + notification_id=notification_id, + worker_lease_token=worker_lease_token, + project_discord_channel_id=claimed_mapping_id, + ) + except Exception: + await self._fail_project_payment_delivery( + notification_id=notification_id, + project_discord_channel_id=claimed_mapping_id, + lease_token=lease_token, + error="notification_context_refresh_failed", + ) + return {"error": "payment_notification_context_unavailable"}, 503 + if context is None: + await self._fail_project_payment_delivery( + notification_id=notification_id, + project_discord_channel_id=claimed_mapping_id, + lease_token=lease_token, + error="notification_no_longer_eligible", + ) + return {"error": "payment_notification_not_eligible"}, 409 + + marker = f"" + try: + lease_renewed = await asyncio.to_thread( + renew_project_payment_discord_delivery_lease, + settings, + notification_id=notification_id, + worker_lease_token=worker_lease_token, + project_discord_channel_id=claimed_mapping_id, + lease_token=lease_token, + ) + except Exception: + logger.warning( + "Failed renewing project payment delivery lease before history id=%s", + notification_id, + exc_info=True, + ) + return {"error": "payment_notification_delivery_lease_unavailable"}, 503 + if not lease_renewed: + return {"error": "payment_notification_delivery_lease_lost"}, 503 + try: + async for message in channel.history(limit=100, oldest_first=False): + if marker in str(message.content or ""): + receipt_saved = await asyncio.to_thread( + mark_project_payment_discord_delivery_sent, + settings, + notification_id=notification_id, + project_discord_channel_id=claimed_mapping_id, + discord_message_id=str(message.id), + lease_token=lease_token, + ) + if not receipt_saved: + return { + "error": "payment_notification_delivery_lease_lost" + }, 503 + await self._record_project_channel_verification( + mapping_id=claimed_mapping_id + ) + return { + "status": "already_delivered", + "message_id": str(message.id), + }, 200 + except discord.Forbidden: + await self._fail_project_payment_delivery( + notification_id=notification_id, + project_discord_channel_id=claimed_mapping_id, + lease_token=lease_token, + error="message_history_forbidden", + ) + return {"error": "message_history_forbidden"}, 403 + except discord.HTTPException: + await self._fail_project_payment_delivery( + notification_id=notification_id, + project_discord_channel_id=claimed_mapping_id, + lease_token=lease_token, + error="message_history_lookup_failed", + ) + return {"error": "message_history_lookup_failed"}, 503 + + # Re-check eligibility and renew immediately before the non-idempotent + # Discord write. This narrows the unavoidable DB-to-Discord race and + # never uses values supplied by the worker's HTTP request. + try: + context = await asyncio.to_thread( + get_project_payment_notification_delivery_context, + settings, + notification_id=notification_id, + worker_lease_token=worker_lease_token, + project_discord_channel_id=claimed_mapping_id, + ) + except Exception: + await self._fail_project_payment_delivery( + notification_id=notification_id, + project_discord_channel_id=claimed_mapping_id, + lease_token=lease_token, + error="notification_context_refresh_failed", + ) + return {"error": "payment_notification_context_unavailable"}, 503 + if context is None: + await self._fail_project_payment_delivery( + notification_id=notification_id, + project_discord_channel_id=claimed_mapping_id, + lease_token=lease_token, + error="notification_no_longer_eligible", + ) + return {"error": "payment_notification_not_eligible"}, 409 + try: + lease_renewed = await asyncio.to_thread( + renew_project_payment_discord_delivery_lease, + settings, + notification_id=notification_id, + worker_lease_token=worker_lease_token, + project_discord_channel_id=claimed_mapping_id, + lease_token=lease_token, + ) + except Exception: + logger.warning( + "Failed renewing project payment delivery lease before send id=%s", + notification_id, + exc_info=True, + ) + return {"error": "payment_notification_delivery_lease_unavailable"}, 503 + if not lease_renewed: + return {"error": "payment_notification_delivery_lease_lost"}, 503 + + try: + # Discord limits a nonce to 25 characters. Keep it deterministic + # for diagnostics without treating it as our correctness boundary; + # the durable receipt and hidden marker remain the recovery source. + discord_nonce = ( + "pp-" + hashlib.sha256(notification_id.encode("utf-8")).hexdigest()[:22] + ) + message = await channel.send( + self._payment_message_content( + notification_id=notification_id, + amount=str(context.amount), + currency=context.currency, + posted_at=( + context.posted_at.isoformat() + if context.posted_at is not None + else None + ), + ), + allowed_mentions=discord.AllowedMentions.none(), + nonce=discord_nonce, + ) + except discord.Forbidden: + await self._fail_project_payment_delivery( + notification_id=notification_id, + project_discord_channel_id=claimed_mapping_id, + lease_token=lease_token, + error="payment_notification_forbidden", + ) + return {"error": "payment_notification_forbidden"}, 403 + except discord.HTTPException as exc: + logger.warning( + "Failed posting project payment notification id=%s: %s", + notification_id, + exc, + ) + await self._fail_project_payment_delivery( + notification_id=notification_id, + project_discord_channel_id=claimed_mapping_id, + lease_token=lease_token, + error="payment_notification_failed", + ) + return {"error": "payment_notification_failed"}, 503 + + receipt_saved = await asyncio.to_thread( + mark_project_payment_discord_delivery_sent, + settings, + notification_id=notification_id, + project_discord_channel_id=claimed_mapping_id, + discord_message_id=str(message.id), + lease_token=lease_token, + ) + if not receipt_saved: + return {"error": "payment_notification_delivery_lease_lost"}, 503 + await self._record_project_channel_verification(mapping_id=claimed_mapping_id) + return { + "status": "sent", + "message_id": str(message.id), + }, 200 + + @app_commands.command( + name="register-project-channel", + description="Register a private Discord channel for a project's payment alerts.", + ) + @app_commands.describe( + project="Open ERP project to receive payment alerts.", + channel="Private text channel. Defaults to the current channel.", + ) + @app_commands.autocomplete(project=project_channel_autocomplete) + @require_role("Steering Committee") + async def register_project_channel( + self, + interaction: discord.Interaction, + project: str, + channel: discord.TextChannel | None = None, + ) -> None: + """Validate and register a live private text channel for a project.""" + guild = interaction.guild + target_channel = self._target_text_channel(interaction, channel) + if guild is None or target_channel is None: + await interaction.response.send_message( + "⚠️ Choose a private text channel inside a server.", ephemeral=True + ) + return + if not self._is_configured_guild(guild.id): + await interaction.response.send_message( + "⚠️ Project payment channels can only be registered in the configured server.", + ephemeral=True, + ) + return + channel_error = self._private_channel_error(guild, target_channel) + if channel_error is not None: + await interaction.response.send_message( + "⚠️ This must be a private text channel that the bot can view and post in.", + ephemeral=True, + ) + self._audit_command_safe( + interaction=interaction, + action="project_channel.register", + result="denied", + metadata={ + "reason": channel_error, + "channel_id": str(target_channel.id), + }, + ) + return + + await interaction.response.defer(ephemeral=True) + try: + active_projects = await asyncio.to_thread( + list_dashboard_projects, + settings, + project_id=project, + status="Open", + include_all=True, + include_roster=False, + limit=1, + ) + except Exception: + logger.warning( + "Failed validating project channel registration", exc_info=True + ) + active_projects = [] + if not active_projects: + await interaction.followup.send( + "⚠️ Choose an active ERP project from the command suggestions.", + ephemeral=True, + ) + self._audit_command_safe( + interaction=interaction, + action="project_channel.register", + result="denied", + metadata={"reason": "project_not_open", "project_id": project}, + ) + return + + try: + mapping, created = await asyncio.to_thread( + register_project_discord_channel, + settings, + project_id=project, + guild_id=str(guild.id), + channel_id=str(target_channel.id), + channel_name=target_channel.name, + registered_by_discord_user_id=str(interaction.user.id), + ) + except ProjectDiscordChannelConflict: + await interaction.followup.send( + "⚠️ This channel is already registered to another project.", + ephemeral=True, + ) + self._audit_command_safe( + interaction=interaction, + action="project_channel.register", + result="denied", + metadata={ + "reason": "channel_already_owned", + "channel_id": str(target_channel.id), + }, + ) + return + except Exception: + logger.warning("Failed registering project payment channel", exc_info=True) + await interaction.followup.send( + "❌ Could not register this project channel. Please try again.", + ephemeral=True, + ) + return + + status = "registered" if created else "already registered" + await interaction.followup.send( + f"✅ <#{target_channel.id}> is {status} for payment alerts.", + ephemeral=True, + ) + self._audit_command_safe( + interaction=interaction, + action="project_channel.register", + result="success", + resource_type="project_discord_channel", + resource_id=mapping.id, + metadata={ + "project_id": project, + "guild_id": str(guild.id), + "channel_id": str(target_channel.id), + "created": created, + }, + ) + + @app_commands.command( + name="unregister-project-channel", + description="Stop payment alerts in a private project channel.", + ) + @app_commands.describe( + channel="Private text channel. Defaults to the current channel." + ) + @require_role("Steering Committee") + async def unregister_project_channel( + self, + interaction: discord.Interaction, + channel: discord.TextChannel | None = None, + ) -> None: + """Deactivate one project payment channel mapping.""" + guild = interaction.guild + target_channel = self._target_text_channel(interaction, channel) + if guild is None or target_channel is None: + await interaction.response.send_message( + "⚠️ Choose a text channel inside a server.", ephemeral=True + ) + return + if not self._is_configured_guild(guild.id): + await interaction.response.send_message( + "⚠️ Project payment channels can only be managed in the configured server.", + ephemeral=True, + ) + return + if target_channel.guild.id != guild.id: + await interaction.response.send_message( + "⚠️ Choose a channel in this server.", ephemeral=True + ) + return + await interaction.response.defer(ephemeral=True) + try: + removed = await asyncio.to_thread( + unregister_project_discord_channel, + settings, + guild_id=str(guild.id), + channel_id=str(target_channel.id), + ) + except Exception: + logger.warning( + "Failed unregistering project payment channel", exc_info=True + ) + await interaction.followup.send( + "❌ Could not unregister this project channel. Please try again.", + ephemeral=True, + ) + return + if removed: + await interaction.followup.send( + f"✅ Payment alerts are disabled for <#{target_channel.id}>.", + ephemeral=True, + ) + else: + await interaction.followup.send( + "ℹ️ This channel was not registered for payment alerts.", + ephemeral=True, + ) + self._audit_command_safe( + interaction=interaction, + action="project_channel.unregister", + result="success", + metadata={ + "guild_id": str(guild.id), + "channel_id": str(target_channel.id), + "removed": removed, + }, + ) + async def setup(bot: commands.Bot) -> None: await bot.add_cog(ProjectsCog(bot)) diff --git a/apps/discord_bot/src/five08/discord_bot/utils/internal_api.py b/apps/discord_bot/src/five08/discord_bot/utils/internal_api.py index f58b8fb0..b31258c7 100644 --- a/apps/discord_bot/src/five08/discord_bot/utils/internal_api.py +++ b/apps/discord_bot/src/five08/discord_bot/utils/internal_api.py @@ -4,11 +4,12 @@ import logging import secrets from typing import Any +from uuid import UUID from aiohttp import web import discord from discord.ext import commands -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, ConfigDict, ValidationError, field_validator from five08.discord_bot.config import settings from five08.engagements import ( @@ -49,6 +50,26 @@ class PostJobLeadRequest(BaseModel): engagement_status: str = EngagementStatus.LEAD.value +class ProjectPaymentNotificationRequest(BaseModel): + """Internal request for one outbox-backed project payment message.""" + + model_config = ConfigDict(extra="forbid") + + notification_id: str + lease_token: str + + @field_validator("notification_id", "lease_token") + @classmethod + def _normalize_notification_id(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("must not be blank") + try: + return str(UUID(normalized)) + except ValueError as exc: + raise ValueError("must be a UUID") from exc + + class InternalAPIRoutes: """Authenticated bot-internal automation routes.""" @@ -75,6 +96,10 @@ def register(self, app: web.Application) -> None: "/internal/jobs/channels", self.job_channels_handler, ) + app.router.add_post( + "/internal/project-payments/notify", + self.project_payment_notification_handler, + ) @staticmethod def _is_authorized(request: web.Request) -> bool: @@ -474,6 +499,22 @@ async def _post_job_lead( ) return result, status_code + async def _post_project_payment_notification( + self, + payload: ProjectPaymentNotificationRequest, + ) -> tuple[dict[str, Any], int]: + """Delegate payment delivery to the project cog's live channel checks.""" + projects_cog = self.bot.get_cog("Projects") + if projects_cog is None or not hasattr( + projects_cog, "post_project_payment_notification" + ): + return {"error": "projects_cog_unavailable"}, 503 + result, status_code = await projects_cog.post_project_payment_notification( + notification_id=payload.notification_id, + worker_lease_token=payload.lease_token, + ) + return result, status_code + async def _list_job_channels( self, *, @@ -526,3 +567,26 @@ async def post_job_lead_handler(self, request: web.Request) -> web.Response: result, status_code = await self._post_job_lead(payload) return web.json_response(result, status=status_code) + + async def project_payment_notification_handler( + self, + request: web.Request, + ) -> web.Response: + """Post one outbox-backed payment message after bot-side validation.""" + if not self._is_authorized(request): + return web.json_response({"error": "unauthorized"}, status=401) + try: + payload_data = await request.json() + except Exception: + return web.json_response({"error": "invalid_json"}, status=400) + if not isinstance(payload_data, dict): + return web.json_response({"error": "payload_must_be_object"}, status=400) + try: + payload = ProjectPaymentNotificationRequest.model_validate(payload_data) + except (ValidationError, TypeError) as exc: + return web.json_response( + {"error": "invalid_payload", "detail": str(exc)}, + status=400, + ) + result, status_code = await self._post_project_payment_notification(payload) + return web.json_response(result, status=status_code) diff --git a/apps/worker/src/five08/worker/erpnext_bank_transaction_sync.py b/apps/worker/src/five08/worker/erpnext_bank_transaction_sync.py new file mode 100644 index 00000000..cd39f68a --- /dev/null +++ b/apps/worker/src/five08/worker/erpnext_bank_transaction_sync.py @@ -0,0 +1,183 @@ +"""ERPNext Bank Transaction ingestion into the local payment automation layer.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any + +from five08.automation import ( + AutomationEventInput, + AutomationRuleMode, + evaluate_automation_rules, +) +from five08.automation_store import ( + list_enabled_automation_rules, + list_retryable_automation_action_ids_for_event, + persist_automation_event_and_evaluation, +) +from five08.clients.erpnext import ERPNextAPIError, ERPNextClient +from five08.project_payments import ( + BANK_TRANSACTION_POSTED_EVENT, + PROJECT_PAYMENT_ALLOWED_ACTION_TYPES, + PROJECT_PAYMENT_ALLOWED_FACT_PATHS, + erpnext_bank_transaction_to_input, + payment_automation_facts, + payment_automation_subject_snapshot, + project_payment_evaluation_for_execution, + project_payment_rule_for_evaluation, + upsert_bank_transaction, +) +from five08.settings import SharedSettings +from five08.worker.config import settings + + +logger = logging.getLogger(__name__) + + +class ERPNextBankTransactionProcessor: + """Fetch canonical ERP transactions before they enter project automation.""" + + def __init__(self, app_settings: SharedSettings = settings) -> None: + self.settings = app_settings + base_url = (app_settings.erpnext_base_url or "").strip() + api_key = (app_settings.erpnext_api_key or "").strip() + if not base_url or not api_key: + raise ValueError("ERPNEXT_BASE_URL and ERPNEXT_API_KEY must be configured") + self.client = ERPNextClient( + base_url, + api_key, + timeout_seconds=app_settings.erpnext_api_timeout_seconds, + ) + + def ingest_bank_transaction( + self, + *, + transaction_name: str, + event_type: str = BANK_TRANSACTION_POSTED_EVENT, + source_revision: str | None = None, + ) -> dict[str, Any]: + """Fetch, sanitize, project, and evaluate one submitted bank transaction.""" + normalized_name = transaction_name.strip() + if not normalized_name: + raise ValueError("transaction_name is required") + if event_type != BANK_TRANSACTION_POSTED_EVENT: + raise ValueError(f"Unsupported Bank Transaction event type: {event_type}") + + try: + try: + document = self.client.get_bank_transaction(normalized_name) + except ERPNextAPIError as exc: + if exc.status_code == 404: + return { + "status": "ignored", + "reason": "bank_transaction_not_found", + "transaction_name": normalized_name, + } + raise + + transaction = erpnext_bank_transaction_to_input(document) + if transaction is None: + return { + "status": "ignored", + "reason": "bank_transaction_not_submitted_or_empty", + "transaction_name": normalized_name, + } + + stored_transaction = upsert_bank_transaction(self.settings, transaction) + if not stored_transaction.accepted: + return { + "status": "ignored", + "reason": "bank_transaction_revision_superseded", + "transaction_name": normalized_name, + "transaction_id": stored_transaction.id, + } + occurred_at = transaction.posted_at or datetime.now(timezone.utc) + if occurred_at.tzinfo is None: + occurred_at = occurred_at.replace(tzinfo=timezone.utc) + # The canonical ERP document's ``modified`` field is the revision + # identity. Webhook metadata is advisory and must never make a + # stale fetch look like a fresh document revision. + revision = transaction.source_revision + event_key = ( + f"erpnext-bank-transaction:v1:{transaction.external_id}:" + f"{revision or 'unversioned'}" + ) + event = AutomationEventInput( + event_id=event_key, + event_type=event_type, + source="erpnext", + subject_id=stored_transaction.id, + occurred_at=occurred_at, + facts=payment_automation_facts(transaction), + subject_revision=revision, + subject_snapshot=payment_automation_subject_snapshot(transaction), + ) + rules = list_enabled_automation_rules( + self.settings, + event_type=event_type, + ) + evaluation_rules = [ + project_payment_rule_for_evaluation(rule) for rule in rules + ] + # A nonempty but unparsable ERPNext ``modified`` value cannot + # establish revision ordering. Keep it reviewable for diagnosis, + # but never emit an automatic money-routing action from it. + if revision is None or transaction.source_modified_at is None: + evaluation_rules = [ + rule.model_copy(update={"mode": AutomationRuleMode.SUGGEST}) + if rule.mode is AutomationRuleMode.AUTOMATIC + else rule + for rule in evaluation_rules + ] + downgraded_rule_ids = [ + original.id + for original, evaluation_rule in zip( + rules, evaluation_rules, strict=True + ) + if original.mode != evaluation_rule.mode + ] + if downgraded_rule_ids: + logger.warning( + "Downgraded unsafe, unversioned, or unorderable automatic payment rule(s) to suggestions: %s", + downgraded_rule_ids, + ) + evaluation = project_payment_evaluation_for_execution( + evaluate_automation_rules( + event, + evaluation_rules, + allowed_fact_paths=PROJECT_PAYMENT_ALLOWED_FACT_PATHS, + allowed_action_types=PROJECT_PAYMENT_ALLOWED_ACTION_TYPES, + ) + ) + stored_event, action_ids = persist_automation_event_and_evaluation( + self.settings, + event=event, + event_key=event_key, + evaluation=evaluation, + ) + if not stored_event.created: + action_ids = list_retryable_automation_action_ids_for_event( + self.settings, + event_id=stored_event.id, + ) + + logger.info( + "Ingested ERPNext Bank Transaction name=%s local_id=%s event_created=%s actions=%s", + normalized_name, + stored_transaction.id, + stored_event.created, + len(action_ids), + ) + return { + "status": "ingested", + "transaction_name": normalized_name, + "transaction_id": stored_transaction.id, + "transaction_created": stored_transaction.created, + "event_id": stored_event.id, + "event_created": stored_event.created, + "action_ids": action_ids, + "direction": transaction.direction.value, + } + finally: + self.client.close() diff --git a/apps/worker/src/five08/worker/jobs.py b/apps/worker/src/five08/worker/jobs.py index 2b96350f..74b7e4db 100644 --- a/apps/worker/src/five08/worker/jobs.py +++ b/apps/worker/src/five08/worker/jobs.py @@ -19,10 +19,15 @@ from five08.worker.crm.processor import ContactSkillsProcessor from five08.worker.crm.resume_profile_processor import ResumeProfileProcessor from five08.worker.erpnext_project_sync import ERPNextProjectSyncProcessor +from five08.worker.erpnext_bank_transaction_sync import ERPNextBankTransactionProcessor +from five08.worker.project_payment_automation import ProjectPaymentActionExecutor +from five08.worker.project_payment_learning import recover_project_payment_learning from five08.worker.mailbox_resume_ingest import ResumeMailboxProcessor from five08.worker.masking import mask_email from five08.newsletter_sync import NewsletterSyncProcessor from five08.job_lead_sources import scrape_job_leads +from five08.automation_store import list_pending_automation_action_ids +from five08.project_payments import list_retryable_project_payment_notification_ids logger = logging.getLogger(__name__) @@ -171,6 +176,115 @@ def sync_projects_from_erpnext_job() -> dict[str, Any]: return processor.sync_open_projects() +def ingest_erpnext_bank_transaction_job( + transaction_name: str, + event_type: str, + source_revision: str, +) -> dict[str, Any]: + """Fetch a submitted ERPNext Bank Transaction and evaluate typed rules.""" + logger.info( + "Processing ERPNext Bank Transaction name=%s event_type=%s", + transaction_name, + event_type, + ) + processor = ERPNextBankTransactionProcessor() + result = processor.ingest_bank_transaction( + transaction_name=transaction_name, + event_type=event_type, + source_revision=source_revision, + ) + action_ids = result.get("action_ids") + if not settings.project_payment_automation_enabled or not isinstance( + action_ids, list + ): + return result + executor = ProjectPaymentActionExecutor() + result["action_results"] = [ + executor.execute_action(str(action_id)) + for action_id in action_ids + if isinstance(action_id, str) + ] + return result + + +def execute_project_payment_automation_action_job(action_id: str) -> dict[str, Any]: + """Execute one persisted project-payment action from the semantic outbox.""" + logger.info("Processing project payment automation action id=%s", action_id) + return ProjectPaymentActionExecutor().execute_action(action_id) + + +def recover_project_payment_automation_job(limit: int = 100) -> dict[str, Any]: + """Sweep durable payment actions/outbox rows after retries or flag changes.""" + if not settings.project_payment_automation_enabled: + return {"status": "disabled"} + + normalized_limit = max(1, min(int(limit), 1000)) + executor = ProjectPaymentActionExecutor() + action_ids = list_pending_automation_action_ids( + settings, + limit=normalized_limit, + ) + action_results: list[dict[str, Any]] = [] + action_failures = 0 + for action_id in action_ids: + try: + action_results.append(executor.execute_action(action_id)) + except Exception: + action_failures += 1 + logger.exception( + "Payment automation recovery failed action_id=%s", action_id + ) + + try: + learning = recover_project_payment_learning( + settings, + limit=normalized_limit, + ) + except Exception: + # Payment execution and recovery must remain available if the optional + # feedback compiler encounters an unexpected persistence failure. + logger.exception("Payment suggestion learning recovery failed") + learning = { + "attempted": 0, + "created": 0, + "existing": 0, + "ineligible": 0, + "failed": 1, + } + + notification_results: list[dict[str, Any]] = [] + notification_failures = 0 + if settings.project_payment_notifications_enabled: + notification_ids = list_retryable_project_payment_notification_ids( + settings, + limit=normalized_limit, + ) + for notification_id in notification_ids: + try: + notification_results.append( + executor.deliver_notification(notification_id) + ) + except Exception: + notification_failures += 1 + logger.exception( + "Payment notification recovery failed notification_id=%s", + notification_id, + ) + else: + notification_ids = [] + + return { + "status": "recovered", + "action_ids": action_ids, + "action_results": action_results, + "action_failures": action_failures, + "learning": learning, + "notification_ids": notification_ids, + "notification_results": notification_results, + "notification_failures": notification_failures, + } + + def _mask_newsletter_sync_result(result: dict[str, Any]) -> dict[str, Any]: """Mask email addresses before newsletter sync results are persisted.""" crm_failures = result.get("crm_lookup_failures") @@ -240,6 +354,11 @@ def scrape_job_leads_job( sync_people_from_crm_job.__name__: sync_people_from_crm_job, sync_person_from_crm_job.__name__: sync_person_from_crm_job, sync_projects_from_erpnext_job.__name__: sync_projects_from_erpnext_job, + ingest_erpnext_bank_transaction_job.__name__: ingest_erpnext_bank_transaction_job, + execute_project_payment_automation_action_job.__name__: ( + execute_project_payment_automation_action_job + ), + recover_project_payment_automation_job.__name__: recover_project_payment_automation_job, sync_508_members_newsletters_job.__name__: sync_508_members_newsletters_job, process_docuseal_agreement_job.__name__: process_docuseal_agreement_job, scrape_job_leads_job.__name__: scrape_job_leads_job, diff --git a/apps/worker/src/five08/worker/migrations/versions/20260716_0100_create_payment_automation_tables.py b/apps/worker/src/five08/worker/migrations/versions/20260716_0100_create_payment_automation_tables.py new file mode 100644 index 00000000..e56ef4ca --- /dev/null +++ b/apps/worker/src/five08/worker/migrations/versions/20260716_0100_create_payment_automation_tables.py @@ -0,0 +1,545 @@ +"""Create typed automation, bank transaction, and project payment tables.""" + +from __future__ import annotations + +from typing import Any + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision = "20260716_0100" +down_revision = "20260711_0100" +branch_labels = None +depends_on = None + + +def _timestamps() -> list[sa.Column[Any]]: + """Shared timestamp columns for durable operational records.""" + return [ + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("NOW()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("NOW()"), + ), + ] + + +def upgrade() -> None: + """Add a durable event/action layer and payment notification outbox.""" + op.create_table( + "automation_events", + sa.Column( + "id", postgresql.UUID(as_uuid=True), nullable=False, primary_key=True + ), + sa.Column("event_key", sa.Text(), nullable=False), + sa.Column("event_type", sa.Text(), nullable=False), + sa.Column("source", sa.Text(), nullable=False), + sa.Column("subject_id", sa.Text(), nullable=False), + sa.Column("subject_revision", sa.Text(), nullable=True), + sa.Column( + "subject_snapshot", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False), + sa.Column( + "facts", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.Column( + "received_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("NOW()"), + ), + sa.UniqueConstraint("event_key", name="uq_automation_events_event_key"), + ) + op.create_index( + "idx_automation_events_type_occurred_at", + "automation_events", + ["event_type", "occurred_at"], + ) + + op.create_table( + "automation_rules", + sa.Column( + "id", postgresql.UUID(as_uuid=True), nullable=False, primary_key=True + ), + sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("event_type", sa.Text(), nullable=False), + sa.Column("priority", sa.Integer(), nullable=False, server_default="0"), + sa.Column("mode", sa.Text(), nullable=False, server_default="suggest"), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("version", sa.Integer(), nullable=False, server_default="1"), + sa.Column( + "conditions", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'[]'::jsonb"), + ), + sa.Column( + "actions", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'[]'::jsonb"), + ), + sa.Column("created_by", sa.Text(), nullable=True), + sa.ForeignKeyConstraint( + ["project_id"], ["projects.id"], name="fk_automation_rules_project_id" + ), + sa.CheckConstraint( + "mode IN ('observe', 'suggest', 'automatic')", + name="ck_automation_rules_mode", + ), + sa.CheckConstraint("version >= 1", name="ck_automation_rules_version"), + *_timestamps(), + ) + op.create_index( + "idx_automation_rules_event_enabled_priority", + "automation_rules", + ["event_type", "enabled", "priority"], + ) + op.create_index( + "idx_automation_rules_project_id", "automation_rules", ["project_id"] + ) + + op.create_table( + "automation_rule_evaluations", + sa.Column( + "id", postgresql.UUID(as_uuid=True), nullable=False, primary_key=True + ), + sa.Column("event_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("rule_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("rule_version", sa.Integer(), nullable=False), + sa.Column("rule_project_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("matched", sa.Boolean(), nullable=False), + sa.Column( + "condition_trace", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'[]'::jsonb"), + ), + sa.Column( + "rule_snapshot", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.Column( + "evaluated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("NOW()"), + ), + sa.ForeignKeyConstraint( + ["event_id"], ["automation_events.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["rule_id"], ["automation_rules.id"]), + sa.ForeignKeyConstraint(["rule_project_id"], ["projects.id"]), + sa.UniqueConstraint( + "event_id", + "rule_id", + "rule_version", + name="uq_automation_rule_evaluation_version", + ), + ) + op.create_index( + "idx_automation_rule_evaluations_event_id", + "automation_rule_evaluations", + ["event_id"], + ) + op.create_index( + "idx_automation_rule_evaluations_rule_id", + "automation_rule_evaluations", + ["rule_id"], + ) + + op.create_table( + "automation_actions", + sa.Column( + "id", postgresql.UUID(as_uuid=True), nullable=False, primary_key=True + ), + sa.Column("event_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("rule_evaluation_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("action_type", sa.Text(), nullable=False), + sa.Column( + "payload", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.Column("mode", sa.Text(), nullable=False), + sa.Column("disposition", sa.Text(), nullable=False), + sa.Column("status", sa.Text(), nullable=False, server_default="pending"), + sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"), + sa.Column("locked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("lease_token", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("approved_by", sa.Text(), nullable=True), + sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("review_decision", sa.Text(), nullable=True), + sa.Column("reviewed_by", sa.Text(), nullable=True), + sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("result", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("idempotency_key", sa.Text(), nullable=False), + sa.ForeignKeyConstraint( + ["event_id"], ["automation_events.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["rule_evaluation_id"], + ["automation_rule_evaluations.id"], + ondelete="CASCADE", + ), + sa.CheckConstraint( + "mode IN ('observe', 'suggest', 'automatic')", + name="ck_automation_actions_mode", + ), + sa.CheckConstraint( + "disposition IN ('observed', 'suggested', 'ready')", + name="ck_automation_actions_disposition", + ), + sa.CheckConstraint( + "status IN ('pending', 'awaiting_review', 'approved', 'running', 'succeeded', 'failed', 'dead')", + name="ck_automation_actions_status", + ), + sa.CheckConstraint( + "review_decision IS NULL OR review_decision IN ('approved', 'rejected')", + name="ck_automation_actions_review_decision", + ), + sa.UniqueConstraint( + "idempotency_key", name="uq_automation_actions_idempotency_key" + ), + *_timestamps(), + ) + op.create_index( + "idx_automation_actions_status_created_at", + "automation_actions", + ["status", "created_at"], + ) + op.create_index( + "idx_automation_actions_event_id", "automation_actions", ["event_id"] + ) + op.create_index( + "idx_automation_actions_rule_evaluation_id", + "automation_actions", + ["rule_evaluation_id"], + ) + op.create_index( + "idx_automation_actions_review_decision_reviewed_at", + "automation_actions", + ["review_decision", "reviewed_at"], + ) + + op.create_table( + "bank_transactions", + sa.Column( + "id", postgresql.UUID(as_uuid=True), nullable=False, primary_key=True + ), + sa.Column("source", sa.Text(), nullable=False), + sa.Column("external_id", sa.Text(), nullable=False), + sa.Column("source_revision", sa.Text(), nullable=True), + sa.Column("source_modified_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("transaction_id", sa.Text(), nullable=True), + sa.Column("posted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("bank_account", sa.Text(), nullable=True), + sa.Column("direction", sa.Text(), nullable=False), + sa.Column("amount", sa.Numeric(18, 6), nullable=False), + sa.Column("currency", sa.Text(), nullable=True), + sa.Column("counterparty", sa.Text(), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("reference_number", sa.Text(), nullable=True), + sa.Column( + "reconciliation_entries", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'[]'::jsonb"), + ), + sa.Column( + "source_payload", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.CheckConstraint( + "direction IN ('inbound', 'outbound')", + name="ck_bank_transactions_direction", + ), + sa.CheckConstraint("amount > 0", name="ck_bank_transactions_amount"), + sa.UniqueConstraint( + "source", "external_id", name="uq_bank_transactions_source_external_id" + ), + *_timestamps(), + ) + op.create_index( + "idx_bank_transactions_source_transaction_id", + "bank_transactions", + ["source", "transaction_id"], + ) + op.create_index( + "idx_bank_transactions_posted_at", "bank_transactions", ["posted_at"] + ) + + op.create_table( + "project_payment_allocations", + sa.Column( + "id", postgresql.UUID(as_uuid=True), nullable=False, primary_key=True + ), + sa.Column( + "payment_transaction_id", postgresql.UUID(as_uuid=True), nullable=False + ), + sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("amount", sa.Numeric(18, 6), nullable=False), + sa.Column("currency", sa.Text(), nullable=True), + sa.Column("status", sa.Text(), nullable=False), + sa.Column("superseded_reason", sa.Text(), nullable=True), + sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("match_method", sa.Text(), nullable=False), + sa.Column("automation_action_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column( + "evidence", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.Column("idempotency_key", sa.Text(), nullable=False), + sa.ForeignKeyConstraint( + ["payment_transaction_id"], ["bank_transactions.id"], ondelete="RESTRICT" + ), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["automation_action_id"], ["automation_actions.id"]), + sa.CheckConstraint("amount > 0", name="ck_project_payment_allocations_amount"), + sa.CheckConstraint( + "status IN ('proposed', 'confirmed', 'rejected', 'superseded')", + name="ck_project_payment_allocations_status", + ), + sa.CheckConstraint( + "match_method IN ('erp_reconciled', 'configured_rule', 'manual', 'learned_suggestion')", + name="ck_project_payment_allocations_match_method", + ), + sa.UniqueConstraint( + "idempotency_key", name="uq_project_payment_allocations_idempotency_key" + ), + sa.UniqueConstraint( + "automation_action_id", + name="uq_project_payment_allocations_automation_action_id", + ), + *_timestamps(), + ) + op.create_index( + "idx_project_payment_allocations_project_status", + "project_payment_allocations", + ["project_id", "status"], + ) + op.create_index( + "idx_project_payment_allocations_transaction_id", + "project_payment_allocations", + ["payment_transaction_id"], + ) + + op.create_table( + "project_discord_channels", + sa.Column( + "id", postgresql.UUID(as_uuid=True), nullable=False, primary_key=True + ), + sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("guild_id", sa.Text(), nullable=False), + sa.Column("channel_id", sa.Text(), nullable=False), + sa.Column("channel_name", sa.Text(), nullable=True), + sa.Column("active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("registered_by_discord_user_id", sa.Text(), nullable=True), + sa.Column("last_verified_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_verification_error", sa.Text(), nullable=True), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="RESTRICT"), + *_timestamps(), + ) + op.create_index( + "uq_project_discord_channels_active_target", + "project_discord_channels", + ["guild_id", "channel_id"], + unique=True, + postgresql_where=sa.text("active IS TRUE"), + ) + op.create_index( + "idx_project_discord_channels_project_active", + "project_discord_channels", + ["project_id", "active"], + ) + + op.create_table( + "project_payment_notification_outbox", + sa.Column( + "id", postgresql.UUID(as_uuid=True), nullable=False, primary_key=True + ), + sa.Column("allocation_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "project_discord_channel_id", postgresql.UUID(as_uuid=True), nullable=False + ), + sa.Column("idempotency_key", sa.Text(), nullable=False), + sa.Column( + "payload", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.Column("status", sa.Text(), nullable=False, server_default="pending"), + sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"), + sa.Column("locked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("lease_token", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("discord_message_id", sa.Text(), nullable=True), + sa.Column("last_error", sa.Text(), nullable=True), + sa.ForeignKeyConstraint( + ["allocation_id"], ["project_payment_allocations.id"], ondelete="RESTRICT" + ), + sa.ForeignKeyConstraint( + ["project_discord_channel_id"], + ["project_discord_channels.id"], + ondelete="RESTRICT", + ), + sa.CheckConstraint( + "status IN ('pending', 'sending', 'sent', 'blocked', 'failed', 'dead')", + name="ck_project_payment_notification_outbox_status", + ), + sa.UniqueConstraint( + "idempotency_key", name="uq_project_payment_notification_outbox_key" + ), + sa.UniqueConstraint( + "allocation_id", + "project_discord_channel_id", + name="uq_project_payment_notification_outbox_allocation_channel", + ), + *_timestamps(), + ) + op.create_index( + "idx_project_payment_notification_outbox_status_created", + "project_payment_notification_outbox", + ["status", "created_at"], + ) + + # The bot owns this receipt/lease. It follows the worker outbox so the + # receipt can hold an FK to its canonical notification and mapping rather + # than trusting request-provided payment/channel fields. + op.create_table( + "project_payment_discord_deliveries", + sa.Column( + "notification_id", + postgresql.UUID(as_uuid=True), + nullable=False, + primary_key=True, + ), + sa.Column( + "project_discord_channel_id", + postgresql.UUID(as_uuid=True), + nullable=False, + ), + sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("channel_id", sa.Text(), nullable=False), + sa.Column("status", sa.Text(), nullable=False, server_default="sending"), + sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"), + sa.Column("locked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("lock_token", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("discord_message_id", sa.Text(), nullable=True), + sa.Column("last_error", sa.Text(), nullable=True), + sa.ForeignKeyConstraint( + ["notification_id"], + ["project_payment_notification_outbox.id"], + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["project_discord_channel_id"], + ["project_discord_channels.id"], + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="RESTRICT"), + sa.CheckConstraint( + "status IN ('sending', 'sent', 'failed')", + name="ck_project_payment_discord_deliveries_status", + ), + *_timestamps(), + ) + op.create_index( + "idx_project_payment_discord_deliveries_status_locked", + "project_payment_discord_deliveries", + ["status", "locked_at"], + ) + + +def downgrade() -> None: + """Remove payment automation tables in dependent order.""" + op.drop_index( + "idx_project_payment_discord_deliveries_status_locked", + table_name="project_payment_discord_deliveries", + ) + op.drop_table("project_payment_discord_deliveries") + op.drop_index( + "idx_project_payment_notification_outbox_status_created", + table_name="project_payment_notification_outbox", + ) + op.drop_table("project_payment_notification_outbox") + op.drop_index( + "idx_project_discord_channels_project_active", + table_name="project_discord_channels", + ) + op.drop_index( + "uq_project_discord_channels_active_target", + table_name="project_discord_channels", + ) + op.drop_table("project_discord_channels") + op.drop_index( + "idx_project_payment_allocations_transaction_id", + table_name="project_payment_allocations", + ) + op.drop_index( + "idx_project_payment_allocations_project_status", + table_name="project_payment_allocations", + ) + op.drop_table("project_payment_allocations") + op.drop_index("idx_bank_transactions_posted_at", table_name="bank_transactions") + op.drop_index( + "idx_bank_transactions_source_transaction_id", table_name="bank_transactions" + ) + op.drop_table("bank_transactions") + op.drop_index( + "idx_automation_actions_status_created_at", table_name="automation_actions" + ) + op.drop_index( + "idx_automation_actions_review_decision_reviewed_at", + table_name="automation_actions", + ) + op.drop_index( + "idx_automation_actions_rule_evaluation_id", + table_name="automation_actions", + ) + op.drop_index("idx_automation_actions_event_id", table_name="automation_actions") + op.drop_table("automation_actions") + op.drop_index( + "idx_automation_rule_evaluations_rule_id", + table_name="automation_rule_evaluations", + ) + op.drop_index( + "idx_automation_rule_evaluations_event_id", + table_name="automation_rule_evaluations", + ) + op.drop_table("automation_rule_evaluations") + op.drop_index("idx_automation_rules_project_id", table_name="automation_rules") + op.drop_index( + "idx_automation_rules_event_enabled_priority", table_name="automation_rules" + ) + op.drop_table("automation_rules") + op.drop_index( + "idx_automation_events_type_occurred_at", table_name="automation_events" + ) + op.drop_table("automation_events") diff --git a/apps/worker/src/five08/worker/migrations/versions/20260716_0101_add_payment_rule_learning_provenance.py b/apps/worker/src/five08/worker/migrations/versions/20260716_0101_add_payment_rule_learning_provenance.py new file mode 100644 index 00000000..7fa9843a --- /dev/null +++ b/apps/worker/src/five08/worker/migrations/versions/20260716_0101_add_payment_rule_learning_provenance.py @@ -0,0 +1,63 @@ +"""Add durable provenance for feedback-derived payment suggestion rules.""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +revision = "20260716_0101" +down_revision = "20260716_0100" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Give each payment rule an explicit configured/learned provenance.""" + op.add_column( + "automation_rules", + sa.Column( + "origin", + sa.Text(), + nullable=False, + server_default="configured", + ), + ) + op.add_column( + "automation_rules", sa.Column("learning_key", sa.Text(), nullable=True) + ) + op.create_check_constraint( + "ck_automation_rules_origin", + "automation_rules", + "origin IN ('configured', 'learned')", + ) + op.create_check_constraint( + "ck_automation_rules_learning_provenance", + "automation_rules", + "(origin = 'configured' AND learning_key IS NULL) OR " + "(origin = 'learned' AND learning_key IS NOT NULL)", + ) + op.create_index( + "uq_automation_rules_learning_key", + "automation_rules", + ["learning_key"], + unique=True, + postgresql_where=sa.text("learning_key IS NOT NULL"), + ) + + +def downgrade() -> None: + """Remove learned-rule provenance while retaining the base automation schema.""" + op.drop_index("uq_automation_rules_learning_key", table_name="automation_rules") + op.drop_constraint( + "ck_automation_rules_learning_provenance", + "automation_rules", + type_="check", + ) + op.drop_constraint( + "ck_automation_rules_origin", + "automation_rules", + type_="check", + ) + op.drop_column("automation_rules", "learning_key") + op.drop_column("automation_rules", "origin") diff --git a/apps/worker/src/five08/worker/models.py b/apps/worker/src/five08/worker/models.py index 6384907b..cc812b64 100644 --- a/apps/worker/src/five08/worker/models.py +++ b/apps/worker/src/five08/worker/models.py @@ -15,7 +15,14 @@ ResumeSkipReason as SharedResumeSkipReason, SkillAttributes as SharedSkillAttributes, ) -from pydantic import AliasChoices, BaseModel, Field, field_validator, model_validator +from pydantic import ( + AliasChoices, + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) ExtractedSkills = SharedExtractedSkills ResumeApplyResult = SharedResumeApplyResult @@ -94,6 +101,30 @@ class DocusealWebhookPayload(BaseModel): data: DocusealSubmitter +class ERPNextBankTransactionWebhookPayload(BaseModel): + """Small signed hint that tells the worker which ERP record to fetch. + + The full Bank Transaction is always read from ERPNext by the worker. This + prevents webhook templates or replayed payloads from becoming accounting + source data. + """ + + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + doctype: Literal["Bank Transaction"] + event_type: Literal["bank_transaction.posted.v1"] + name: str + modified: str + docstatus: Literal[1] + + @field_validator("name", "modified") + @classmethod + def _require_nonblank_identifier(cls, value: str) -> str: + if not value: + raise ValueError("must not be blank") + return value + + class TallyWebhookFieldOption(BaseModel): """Single select/matrix option metadata from a Tally webhook field.""" diff --git a/apps/worker/src/five08/worker/project_payment_automation.py b/apps/worker/src/five08/worker/project_payment_automation.py new file mode 100644 index 00000000..9e39a29e --- /dev/null +++ b/apps/worker/src/five08/worker/project_payment_automation.py @@ -0,0 +1,510 @@ +"""Execute project-payment actions and their Discord notification outbox.""" + +from __future__ import annotations + +import logging +from typing import Any + +from five08.automation_store import ( + automation_action_subject_id, + claim_automation_action, + mark_automation_action_failed, +) +from five08.clients.discord_bot import DiscordBotAPIError, DiscordBotClient +from five08.clients.erpnext import ERPNextAPIError, ERPNextClient +from five08.project_payments import ( + PROJECT_PAYMENT_ROUTE_ACTION, + ProjectPaymentActionApplicationStatus, + ProjectPaymentAllocationStatus, + apply_project_payment_automation_action, + block_project_payment_notification_if_ineligible, + claim_project_payment_notification, + erpnext_bank_transaction_to_input, + get_bank_transaction, + get_project_payment_notification_source_context, + mark_project_payment_notification_failed, + mark_project_payment_notification_sent, + upsert_bank_transaction, +) +from five08.projects import ( + erpnext_project_external_id, + erpnext_project_to_input, + upsert_project, +) +from five08.settings import SharedSettings +from five08.worker.config import settings + + +logger = logging.getLogger(__name__) + + +class ProjectPaymentActionPreflightBlocked(Exception): + """A canonical source recheck proves a claimed action cannot proceed.""" + + +class ProjectPaymentActionExecutor: + """Executor for the registered v1 project-payment action type. + + The allocation itself is deliberately not assembled here. It lives in + ``apply_project_payment_automation_action`` so its action lease, rule + version, ERP source revision, project state, allocation, and notification + outbox commit as one database transaction. + """ + + def __init__(self, app_settings: SharedSettings = settings) -> None: + self.settings = app_settings + + def execute_action(self, action_id: str) -> dict[str, Any]: + """Claim and apply one durable payment-routing action.""" + if not bool( + getattr(self.settings, "project_payment_automation_enabled", False) + ): + return {"status": "disabled", "action_id": action_id} + + action = claim_automation_action(self.settings, action_id=action_id) + if action is None: + return {"status": "not_claimed", "action_id": action_id} + if action.lease_token is None: + logger.error("Claimed payment action has no lease token id=%s", action.id) + return {"status": "not_claimed", "action_id": action.id} + if action.action_type != PROJECT_PAYMENT_ROUTE_ACTION: + mark_automation_action_failed( + self.settings, + action_id=action.id, + error=f"unregistered payment action type: {action.action_type}", + dead=True, + lease_token=action.lease_token, + ) + return { + "status": "ignored", + "action_id": action.id, + "reason": "unregistered_action_type", + } + + try: + self._refresh_bank_transaction_from_erpnext(action.id) + if action.rule_project_id: + self._refresh_project_from_erpnext(action.rule_project_id) + applied = apply_project_payment_automation_action( + self.settings, + action_id=action.id, + lease_token=action.lease_token, + ) + except ProjectPaymentActionPreflightBlocked as exc: + mark_automation_action_failed( + self.settings, + action_id=action.id, + error=str(exc), + dead=True, + lease_token=action.lease_token, + ) + return { + "status": "blocked", + "action_id": action.id, + "reason": str(exc), + } + except Exception as exc: + mark_automation_action_failed( + self.settings, + action_id=action.id, + error=f"{type(exc).__name__}: {exc}", + lease_token=action.lease_token, + ) + raise + + if applied.status is ProjectPaymentActionApplicationStatus.NOT_OWNER: + return {"status": "not_claimed", "action_id": action.id} + if applied.status is ProjectPaymentActionApplicationStatus.BLOCKED: + return { + "status": "blocked", + "action_id": action.id, + "reason": applied.reason, + } + + delivered_notification_ids: list[str] = [] + if bool(getattr(self.settings, "project_payment_notifications_enabled", False)): + for notification_id in applied.notification_ids: + delivery = self.deliver_notification(notification_id) + if delivery["status"] == "sent": + delivered_notification_ids.append(notification_id) + + return { + "status": "succeeded", + "action_id": action.id, + "allocation_id": applied.allocation_id, + "allocation_created": applied.allocation_created, + "notification_ids": list(applied.notification_ids), + "delivered_notification_ids": delivered_notification_ids, + } + + def _erpnext_client(self) -> ERPNextClient: + """Create an authenticated ERP client for an action-time recheck.""" + base_url = str(getattr(self.settings, "erpnext_base_url", "") or "").strip() + api_key = str(getattr(self.settings, "erpnext_api_key", "") or "").strip() + if not base_url or not api_key: + raise ValueError("payment_routing_requires_erpnext_credentials") + return ERPNextClient( + base_url, + api_key, + timeout_seconds=float( + getattr(self.settings, "erpnext_api_timeout_seconds", 20.0) + ), + ) + + def _refresh_bank_transaction_from_erpnext(self, action_id: str) -> None: + """Reproject the canonical Bank Transaction before a money action. + + The submit webhook is only an ingestion hint. A delayed or retried + action must not rely on a source row that has since been canceled or + corrected in ERPNext. A newer document revision is written locally and + then rejected by the immutable snapshot fence in the atomic apply. + """ + subject_id = automation_action_subject_id(self.settings, action_id=action_id) + if subject_id is None: + raise ProjectPaymentActionPreflightBlocked( + "payment_event_has_no_transaction_subject" + ) + stored_transaction = get_bank_transaction( + self.settings, + transaction_id=subject_id, + ) + if stored_transaction is None: + raise ProjectPaymentActionPreflightBlocked("payment_transaction_not_found") + if stored_transaction.source != "erpnext": + raise ProjectPaymentActionPreflightBlocked( + "payment_transaction_source_is_not_erpnext" + ) + + client = self._erpnext_client() + try: + try: + document = client.get_bank_transaction(stored_transaction.external_id) + except ERPNextAPIError as exc: + if exc.status_code == 404: + raise ProjectPaymentActionPreflightBlocked( + "bank_transaction_not_found_in_erpnext" + ) from exc + raise + finally: + client.close() + transaction = erpnext_bank_transaction_to_input(document) + if transaction is None: + raise ProjectPaymentActionPreflightBlocked( + "bank_transaction_is_no_longer_submitted" + ) + if transaction.external_id != stored_transaction.external_id: + raise ProjectPaymentActionPreflightBlocked( + "bank_transaction_identity_changed" + ) + # ERPNext's ``modified`` value is our revision ordering fence. A + # nonempty but unparsable string cannot safely establish which source + # document is newer, so automatic routing must fail closed rather than + # treat it as an opaque revision token. + if ( + transaction.source_revision is None + or transaction.source_modified_at is None + ): + raise ProjectPaymentActionPreflightBlocked( + "bank_transaction_has_no_canonical_revision" + ) + persisted_transaction = upsert_bank_transaction(self.settings, transaction) + if ( + not persisted_transaction.accepted + or persisted_transaction.id != stored_transaction.id + ): + # A stale/unversioned fetch must never leave the old local revision + # looking eligible for the atomic apply below. A fresh canonical + # event/review is required rather than routing money from it. + raise ProjectPaymentActionPreflightBlocked( + "bank_transaction_revision_refresh_was_not_accepted" + ) + + def _refresh_project_from_erpnext(self, project_id: str | None) -> None: + """Refresh the project from ERPNext immediately before payment routing. + + The local cache is still the transaction/locking boundary, but neither + automatic routing nor an approved suggestion can rely on a manually + refreshed status. A current ERP read updates the cache first; if + ERPNext is unavailable, the action is retried instead of allocating + against stale status. + """ + if not project_id: + # The transactional action boundary produces the durable policy + # failure for an unscoped action. Do not make an unrelated ERP call. + return + external_id = erpnext_project_external_id( + self.settings, + project_id=project_id, + ) + if external_id is None: + raise ValueError("payment_project_has_no_erpnext_identity") + client = self._erpnext_client() + try: + document = client.get_project(external_id) + finally: + client.close() + payload = erpnext_project_to_input(document) + if payload is None or payload.external_id != external_id: + raise ValueError("payment_project_refresh_was_invalid") + if str(payload.source_status or "").strip().casefold() != "open": + raise ProjectPaymentActionPreflightBlocked( + "payment_project_is_not_open_in_erpnext" + ) + if payload.source_modified_at is None: + raise ProjectPaymentActionPreflightBlocked( + "payment_project_has_no_canonical_revision" + ) + refreshed_project_id = upsert_project(self.settings, payload) + if refreshed_project_id != project_id: + raise ValueError("payment_project_identity_changed") + + def _notification_source_block_reason( + self, + *, + notification_id: str, + lease_token: str, + ) -> str | None: + """Return a deterministic reason to suppress a stale notification. + + Allocation-time validation is necessary but not sufficient: a durable + outbox row may wait while Discord is unavailable or notifications are + disabled. Re-read the authoritative ERPNext document immediately + before the external side effect and require its revision to match the + immutable allocation evidence. + + Transient ERP/network errors intentionally propagate to the caller so + the owned outbox lease is released for retry. A missing, canceled, or + revised Bank Transaction is a terminal safety failure instead. + """ + source_context = get_project_payment_notification_source_context( + self.settings, + notification_id=notification_id, + lease_token=lease_token, + ) + if source_context is None: + return "payment_notification_source_context_lost" + if ( + source_context.allocation_status + is not ProjectPaymentAllocationStatus.CONFIRMED + ): + return "payment_allocation_is_not_confirmed" + if source_context.source != "erpnext": + return "payment_transaction_source_is_not_erpnext" + if source_context.allocation_source_revision is None: + return "payment_allocation_has_no_canonical_source_revision" + if ( + source_context.current_source_revision + != source_context.allocation_source_revision + ): + return "payment_source_revision_superseded" + + client = self._erpnext_client() + try: + try: + document = client.get_bank_transaction(source_context.external_id) + except ERPNextAPIError as exc: + if exc.status_code == 404: + return "bank_transaction_not_found_in_erpnext" + raise + finally: + client.close() + transaction = erpnext_bank_transaction_to_input(document) + if transaction is None: + return "bank_transaction_is_no_longer_submitted" + if transaction.external_id != source_context.external_id: + return "bank_transaction_identity_changed" + + # Save the latest canonical projection before deciding whether this + # allocation's immutable evidence is still current. If another sync + # has already observed a newer revision, the re-read below still wins + # and suppresses delivery. + upsert_bank_transaction(self.settings, transaction) + refreshed_transaction = get_bank_transaction( + self.settings, + transaction_id=source_context.bank_transaction_id, + ) + if refreshed_transaction is None: + return "payment_transaction_not_found" + if ( + refreshed_transaction.source != source_context.source + or refreshed_transaction.external_id != source_context.external_id + or refreshed_transaction.source_revision + != source_context.allocation_source_revision + ): + return "payment_source_revision_superseded" + if transaction.source_revision != source_context.allocation_source_revision: + return "payment_source_revision_superseded" + return None + + def deliver_notification(self, notification_id: str) -> dict[str, Any]: + """Request bot delivery of one claimed, current and eligible outbox row.""" + if not bool( + getattr(self.settings, "project_payment_notifications_enabled", False) + ): + return {"status": "disabled", "notification_id": notification_id} + if block_project_payment_notification_if_ineligible( + self.settings, + notification_id=notification_id, + ): + return { + "status": "blocked", + "notification_id": notification_id, + "reason": "project_channel_or_allocation_ineligible", + } + + notification = claim_project_payment_notification( + self.settings, + notification_id=notification_id, + ) + if notification is None: + if block_project_payment_notification_if_ineligible( + self.settings, + notification_id=notification_id, + ): + return { + "status": "blocked", + "notification_id": notification_id, + "reason": "project_channel_or_allocation_ineligible", + } + return {"status": "not_claimed", "notification_id": notification_id} + if notification.lease_token is None: + logger.error( + "Claimed payment notification has no lease token id=%s", + notification.id, + ) + return {"status": "not_claimed", "notification_id": notification.id} + + base_url = str( + getattr(self.settings, "discord_bot_internal_base_url", "") or "" + ).strip() + api_secret = str(getattr(self.settings, "api_shared_secret", "") or "").strip() + if not base_url or not api_secret: + mark_project_payment_notification_failed( + self.settings, + notification_id=notification.id, + error="Discord bot internal endpoint is not configured", + lease_token=notification.lease_token, + ) + return { + # Deployment configuration can be repaired after the payment + # allocation commits. Leave the durable outbox retryable + # rather than permanently suppressing a valid receipt. + "status": "failed", + "notification_id": notification.id, + "reason": "discord_bot_not_configured", + } + + try: + self._refresh_project_from_erpnext(notification.project_id) + except ProjectPaymentActionPreflightBlocked as exc: + blocked = mark_project_payment_notification_failed( + self.settings, + notification_id=notification.id, + error=str(exc), + blocked=True, + lease_token=notification.lease_token, + ) + if not blocked: + return {"status": "not_claimed", "notification_id": notification.id} + return { + "status": "blocked", + "notification_id": notification.id, + "reason": str(exc), + } + except Exception as exc: + mark_project_payment_notification_failed( + self.settings, + notification_id=notification.id, + error=( + "Canonical ERP project notification recheck failed: " + f"{type(exc).__name__}: {exc}" + ), + lease_token=notification.lease_token, + ) + raise + + try: + source_block_reason = self._notification_source_block_reason( + notification_id=notification.id, + lease_token=notification.lease_token, + ) + except Exception as exc: + mark_project_payment_notification_failed( + self.settings, + notification_id=notification.id, + error=( + "Canonical Bank Transaction notification recheck failed: " + f"{type(exc).__name__}: {exc}" + ), + lease_token=notification.lease_token, + ) + raise + if source_block_reason is not None: + blocked = mark_project_payment_notification_failed( + self.settings, + notification_id=notification.id, + error=source_block_reason, + blocked=True, + lease_token=notification.lease_token, + ) + if not blocked: + return {"status": "not_claimed", "notification_id": notification.id} + return { + "status": "blocked", + "notification_id": notification.id, + "reason": source_block_reason, + } + + client = DiscordBotClient(base_url, api_secret) + try: + response = client.post_project_payment_notification( + notification_id=notification.id, + lease_token=notification.lease_token, + ) + except DiscordBotAPIError as exc: + status_code = client.status_code + blocked = status_code in {400, 403, 404, 409} + mark_project_payment_notification_failed( + self.settings, + notification_id=notification.id, + error=str(exc), + blocked=blocked, + lease_token=notification.lease_token, + ) + if blocked: + return { + "status": "blocked", + "notification_id": notification.id, + "reason": "bot_rejected_delivery", + } + raise + + message_id = str(response.get("message_id") or "").strip() + if not message_id: + message = "Discord bot response did not include message_id" + mark_project_payment_notification_failed( + self.settings, + notification_id=notification.id, + error=message, + lease_token=notification.lease_token, + ) + raise RuntimeError(message) + if not mark_project_payment_notification_sent( + self.settings, + notification_id=notification.id, + discord_message_id=message_id, + lease_token=notification.lease_token, + ): + # The bot has a durable receipt. A later outbox retry will ask it + # for the same notification and converge without a second send. + return {"status": "not_claimed", "notification_id": notification.id} + logger.info( + "Delivered project payment notification id=%s message_id=%s", + notification.id, + message_id, + ) + return { + "status": "sent", + "notification_id": notification.id, + "message_id": message_id, + } diff --git a/apps/worker/src/five08/worker/project_payment_learning.py b/apps/worker/src/five08/worker/project_payment_learning.py new file mode 100644 index 00000000..a54476f3 --- /dev/null +++ b/apps/worker/src/five08/worker/project_payment_learning.py @@ -0,0 +1,85 @@ +"""Durably recover suggestion-only rules from approved payment feedback.""" + +from __future__ import annotations + +import logging + +from five08.automation_store import ( + create_automation_rule_if_absent, + list_approved_automation_review_actions, +) +from five08.project_payments import ( + BANK_TRANSACTION_POSTED_EVENT, + PROJECT_PAYMENT_ROUTE_ACTION, + learned_project_payment_suggestion_rule, + project_is_open, +) +from five08.settings import SharedSettings +from five08.worker.config import settings + + +logger = logging.getLogger(__name__) + + +def recover_project_payment_learning( + app_settings: SharedSettings = settings, + *, + limit: int = 100, +) -> dict[str, int]: + """Retry idempotent suggestion learning without replaying allocations. + + Feedback is durable before this function runs. Every attempt derives the + same opaque rule id/key from its immutable ERP snapshot, so a scheduled + retry can safely follow an API-process or database interruption. + """ + reviews = list_approved_automation_review_actions( + app_settings, + event_type=BANK_TRANSACTION_POSTED_EVENT, + action_type=PROJECT_PAYMENT_ROUTE_ACTION, + limit=max(1, min(int(limit), 1000)), + ) + result = { + "attempted": len(reviews), + "created": 0, + "existing": 0, + "ineligible": 0, + "failed": 0, + } + for review in reviews: + project_id = review.action.rule_project_id + if project_id is None: + result["ineligible"] += 1 + continue + try: + if not project_is_open(app_settings, project_id=project_id): + result["ineligible"] += 1 + continue + except Exception: + result["failed"] += 1 + logger.exception( + "Could not validate learned payment project action_id=%s", + review.action.id, + ) + continue + rule = learned_project_payment_suggestion_rule( + project_id=project_id, + subject_snapshot=review.subject_snapshot, + ) + if rule is None: + result["ineligible"] += 1 + continue + try: + _stored_rule, created = create_automation_rule_if_absent( + app_settings, + rule=rule, + created_by=f"system:project-payment-learning:{review.action.id}", + ) + except Exception: + result["failed"] += 1 + logger.exception( + "Could not recover payment learning action_id=%s", + review.action.id, + ) + continue + result["created" if created else "existing"] += 1 + return result diff --git a/compose.yaml b/compose.yaml index 310747c8..dd28cd2d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -85,6 +85,10 @@ services: environment: REDIS_URL: redis://redis:6379/0 REDIS_QUEUE_NAME: ${REDIS_QUEUE_NAME:-jobs.default} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-workflows} + POSTGRES_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-workflows} BACKEND_API_BASE_URL: http://web:8090 AUDIT_API_BASE_URL: "" restart: unless-stopped diff --git a/docs/configuration.md b/docs/configuration.md index 9b8ef81f..65ceaa21 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -77,6 +77,111 @@ dashboard field. `clamdscan --stream --no-summary --config-file=/etc/clamav/clamdscan.conf {path}`. - `INTAKE_RESUME_VIRUS_SCAN_TIMEOUT_SECONDS`: scan command timeout. +## ERPNext Project Payments + +The payment automation consumes ERPNext as the accounting source of truth. It +does not use direct Plaid credentials. + +- `ERPNEXT_BANK_TRANSACTION_WEBHOOK_SIGNING_SECRET`: dedicated Frappe Webhook + Secret for `POST /webhooks/erpnext/bank-transaction`. The endpoint accepts + only `X-Frappe-Webhook-Signature`, a base64 HMAC-SHA256 of the raw body; it + intentionally does not fall back to `WEBHOOK_SHARED_SECRET` or + `API_SHARED_SECRET`. +- `PROJECT_PAYMENT_AUTOMATION_ENABLED`: defaults to `false`. When enabled, a + matched `automatic` payment-routing rule can create a confirmed local project + allocation and notification outbox rows. Rules remain typed and limited to + registered payment facts/action types; they cannot run arbitrary code. +- `PROJECT_PAYMENT_NOTIFICATIONS_ENABLED`: defaults to `false`. When enabled, + the worker asks the Discord bot to deliver pending payment notifications. The + worker re-reads the canonical ERP project and Bank Transaction, requiring the + allocation's captured transaction revision immediately before asking the bot + to post. The bot + then rechecks the private-channel mapping, channel privacy, and its + view/send/read-history permissions immediately before posting. +- `PROJECT_PAYMENT_RECOVERY_INTERVAL_SECONDS`: defaults to `300` (minimum + `60`). While payment automation is enabled, the API schedules this durable + sweep to reclaim stale worker/bot leases, retry idempotent learned-suggestion + derivation from approved feedback, and process actions or notifications + created while a feature flag was off. Restart the API after changing either + `PROJECT_PAYMENT_AUTOMATION_ENABLED` or the interval; notification delivery + itself can be toggled without a scheduler restart. + +These three non-secret settings are intentionally omitted from `.env.example`: +when unset, an authorized operator may set them through runtime configuration. +Set one in deployment environment only when it should be locked outside the +dashboard. + +Configure a signed Frappe Webhook on **Bank Transaction** for **on_submit**, +with JSON body: + +```json +{ + "doctype": "Bank Transaction", + "event_type": "bank_transaction.posted.v1", + "name": "{{ doc.name }}", + "modified": "{{ doc.modified }}", + "docstatus": {{ doc.docstatus }} +} +``` + +To observe corrections proactively, configure a second otherwise-identical +webhook for **on_update_after_submit**. Both webhooks use the same event type +and endpoint; the canonical `modified` revision deduplicates replayed hints and +causes a newer submitted revision to be evaluated afresh. This is in addition +to, not a replacement for, the action-time and notification-time canonical +rechecks. + +The webhook is an identity hint only: the worker fetches the canonical Bank +Transaction over the ERPNext API before persisting or categorizing it. Register +a target with `/register-project-channel` from a private text channel; the +command requires Steering Committee-or-higher access and validates that the bot +can view, post, and read message history there. Use +`/unregister-project-channel` to disable delivery without deleting history. +Before every payment allocation (automatic or human-approved), the worker also +refreshes that project's ERPNext record and writes it to the local cache; a +closed or unavailable ERP project cannot be routed from stale cache state. + +Use the dashboard's **Payments** view to inspect suggestions and create or +disable routing rules. The same protected API remains available to integrations +(requires `configuration:write`): `POST /dashboard/api/project-payment-rules`, +then use the same resource with `PUT` (supplying `expected_version`) or +`DELETE` (soft disable, also supplying `expected_version`). `GET` lists current +rules. The v1 surface permits one `project_payment.route` action per rule and +only the published Bank Transaction fact paths. Its `project_id` and action +payload `project_id` must match an open local ERP project. + +Automatic rules are rejected unless they are project-scoped and combine a +nonblank identity match (`counterparty`, `description`, or `reference_number`), +an exact positive amount, and an exact currency. Amount-only, range, and +reconciliation-only rules are suggestions, never automatic allocations. Closed +projects are intentionally blocked from new automatic allocations or alerts; +review late payments manually. + +Each suggestion approval or rejection stores a typed decision, reviewer, and +timestamp alongside immutable event/rule evidence. When an approval has an +inbound direction, a three-letter currency, and a meaningful counterparty, +reference, or description for an open local ERP project, it also creates (or +reuses) one low-priority +**learned** rule. Learned rules are deterministic, carry an opaque provenance +fingerprint, and can only create future `suggest` actions; they cannot be +edited into or used as automatic payment routing. They remain visible in the +Payments view and can be disabled there. Rejections remain durable labeled +feedback but do not silently mutate an existing rule. + +Discord delivery is at-least-once with a durable bot-side receipt/lease and a +hidden marker recovery check. The bot additionally requires the worker's active +outbox lease with each request, so a notification ID alone cannot bypass its +lifecycle. It avoids normal retry duplicates but cannot make Discord's external +send and the database write one atomic transaction. A delayed notification for +a canceled or revised Bank Transaction is blocked before delivery. When a newer +canonical revision is observed, a prior automatic configured-rule allocation is +marked superseded (with an audit reason) and its pending outbox delivery becomes +ineligible; a fresh event may be evaluated against the new revision. Human- +reviewed, manual, and ERP-reconciled allocations are never superseded by this +automation. This v1 cannot retract a Discord message already accepted, and +cancellations or post-delivery corrections still require accounting +reconciliation. + ## Queue And Jobs - `REDIS_URL` diff --git a/packages/shared/src/five08/automation.py b/packages/shared/src/five08/automation.py new file mode 100644 index 00000000..d975d292 --- /dev/null +++ b/packages/shared/src/five08/automation.py @@ -0,0 +1,429 @@ +"""Typed, deterministic rule evaluation for operational automations. + +The module deliberately evaluates only declarative facts, operators, and action +types. It never executes an action itself. Callers persist the evaluation and +hand only registered action types to a separate, policy-aware executor. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import datetime +from decimal import Decimal, InvalidOperation +from enum import StrEnum +import re +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +_FACT_PATH_COMPONENT_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$") +_ACTION_TYPE_PATTERN = re.compile(r"^[a-z][a-z0-9_.-]*$") +_MISSING = object() + + +class AutomationRuleMode(StrEnum): + """The strongest side-effect level a rule is allowed to request.""" + + OBSERVE = "observe" + SUGGEST = "suggest" + AUTOMATIC = "automatic" + + +class AutomationRuleOrigin(StrEnum): + """Whether an operator configured a rule or feedback derived it.""" + + CONFIGURED = "configured" + LEARNED = "learned" + + +class AutomationConditionOperator(StrEnum): + """Small, auditable set of supported fact predicates.""" + + EQUALS = "equals" + NOT_EQUALS = "not_equals" + CONTAINS = "contains" + IN = "in" + EXISTS = "exists" + GREATER_THAN_OR_EQUAL = "greater_than_or_equal" + LESS_THAN_OR_EQUAL = "less_than_or_equal" + + +class AutomationActionDisposition(StrEnum): + """How an action proposal must be treated by its executor.""" + + OBSERVED = "observed" + SUGGESTED = "suggested" + READY = "ready" + + +class AutomationReviewDecision(StrEnum): + """A human verdict retained as labeled feedback for future suggestions.""" + + APPROVED = "approved" + REJECTED = "rejected" + + +class AutomationEventInput(BaseModel): + """Normalized event facts presented to the deterministic rule engine.""" + + model_config = ConfigDict(extra="forbid") + + event_id: str + event_type: str + source: str + subject_id: str + occurred_at: datetime + facts: dict[str, Any] = Field(default_factory=dict) + # ``subject_snapshot`` is executor-only evidence captured when the event is + # accepted. Rules remain constrained to ``facts`` by their allowlist, so a + # future snapshot field cannot silently become a rule input. + subject_revision: str | None = None + subject_snapshot: dict[str, Any] = Field(default_factory=dict) + + @field_validator("event_id", "event_type", "source", "subject_id") + @classmethod + def _require_identifier(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("must not be blank") + return normalized + + +class AutomationCondition(BaseModel): + """One typed predicate evaluated against a dotted path in event facts.""" + + model_config = ConfigDict(extra="forbid") + + fact: str + operator: AutomationConditionOperator + value: Any = None + + @field_validator("fact") + @classmethod + def _validate_fact_path(cls, value: str) -> str: + normalized = value.strip() + parts = normalized.split(".") + if not normalized or any( + not _FACT_PATH_COMPONENT_PATTERN.fullmatch(part) for part in parts + ): + raise ValueError("fact must be a dotted lowercase identifier path") + return normalized + + @model_validator(mode="after") + def _validate_value_for_operator(self) -> "AutomationCondition": + if self.operator is AutomationConditionOperator.IN and not isinstance( + self.value, (list, tuple, set, frozenset) + ): + raise ValueError("in conditions require a list-like value") + if ( + self.operator + in { + AutomationConditionOperator.GREATER_THAN_OR_EQUAL, + AutomationConditionOperator.LESS_THAN_OR_EQUAL, + } + and _decimal_or_none(self.value) is None + ): + raise ValueError("numeric conditions require a finite numeric value") + return self + + +class AutomationAction(BaseModel): + """A declarative request that a separately registered executor may handle.""" + + model_config = ConfigDict(extra="forbid") + + action_type: str + payload: dict[str, Any] = Field(default_factory=dict) + + @field_validator("action_type") + @classmethod + def _validate_action_type(cls, value: str) -> str: + normalized = value.strip() + if not _ACTION_TYPE_PATTERN.fullmatch(normalized): + raise ValueError("action_type must be a stable lowercase action identifier") + return normalized + + +class AutomationRule(BaseModel): + """Versioned declarative rule that can produce one or more action proposals.""" + + model_config = ConfigDict(extra="forbid") + + id: str + project_id: str | None = None + event_type: str + origin: AutomationRuleOrigin = AutomationRuleOrigin.CONFIGURED + # A non-sensitive, deterministic fingerprint lets feedback-derived rules + # be created idempotently without making source transaction text part of a + # public identifier. + learning_key: str | None = Field(default=None, max_length=200) + priority: int = 0 + mode: AutomationRuleMode = AutomationRuleMode.SUGGEST + enabled: bool = True + version: int = Field(default=1, ge=1) + conditions: list[AutomationCondition] = Field(default_factory=list) + actions: list[AutomationAction] = Field(min_length=1) + + @field_validator("id", "event_type") + @classmethod + def _require_identifier(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("must not be blank") + return normalized + + @model_validator(mode="after") + def _validate_learning_provenance(self) -> "AutomationRule": + learning_key = (self.learning_key or "").strip() or None + if self.origin is AutomationRuleOrigin.LEARNED and learning_key is None: + raise ValueError("learned rules require a learning_key") + if self.origin is AutomationRuleOrigin.CONFIGURED and learning_key is not None: + raise ValueError("configured rules cannot have a learning_key") + self.learning_key = learning_key + return self + + +class ConditionEvaluation(BaseModel): + """Audit-friendly trace of a single predicate evaluation.""" + + model_config = ConfigDict(extra="forbid") + + fact: str + operator: AutomationConditionOperator + matched: bool + reason: str | None = None + + +class MatchedAutomationRule(BaseModel): + """One evaluated rule and its condition trace.""" + + model_config = ConfigDict(extra="forbid") + + rule_id: str + rule_version: int + project_id: str | None = None + priority: int + mode: AutomationRuleMode + matched: bool + conditions: list[ConditionEvaluation] + # This preserves the exact predicate/action definition that was evaluated. + # ``automation_rules`` is intentionally mutable/versioned for operators, + # while payment audit evidence must remain reconstructable after edits. + rule_snapshot: dict[str, Any] = Field(default_factory=dict) + + +class AutomationActionProposal(BaseModel): + """An action proposal resulting from a matched rule.""" + + model_config = ConfigDict(extra="forbid") + + event_id: str + rule_id: str + rule_version: int + rule_project_id: str | None = None + rule_priority: int + mode: AutomationRuleMode + disposition: AutomationActionDisposition + action: AutomationAction + + +class AutomationEvaluation(BaseModel): + """Complete deterministic output for one event/rule-set evaluation.""" + + model_config = ConfigDict(extra="forbid") + + event_id: str + event_type: str + rules: list[MatchedAutomationRule] + action_proposals: list[AutomationActionProposal] + + +def _decimal_or_none(value: Any) -> Decimal | None: + """Safely coerce JSON-compatible values to a finite Decimal.""" + if isinstance(value, bool): + return None + if isinstance(value, Decimal): + candidate = value + elif isinstance(value, (int, float, str)): + try: + candidate = Decimal(str(value).strip()) + except (InvalidOperation, ValueError): + return None + else: + return None + return candidate if candidate.is_finite() else None + + +def resolve_fact_path(facts: Mapping[str, Any], path: str) -> Any: + """Resolve a safe dotted fact path from mappings only. + + Object attributes, indexes, and expressions are intentionally unsupported. + They would make a rule definition executable rather than declarative. + """ + current: Any = facts + for component in path.split("."): + if not isinstance(current, Mapping) or component not in current: + return _MISSING + current = current[component] + return current + + +def _contains(actual: Any, expected: Any) -> bool: + if isinstance(actual, str) and isinstance(expected, str): + return expected.casefold() in actual.casefold() + if isinstance(actual, Mapping): + return expected in actual + if isinstance(actual, Sequence) and not isinstance(actual, (str, bytes, bytearray)): + return expected in actual + return False + + +def _condition_matches(condition: AutomationCondition, actual: Any) -> bool: + if condition.operator is AutomationConditionOperator.EXISTS: + expected = True if condition.value is None else bool(condition.value) + exists = actual is not _MISSING and actual is not None + return exists is expected + if actual is _MISSING: + return False + if condition.operator is AutomationConditionOperator.EQUALS: + return _values_equal(actual, condition.value) + if condition.operator is AutomationConditionOperator.NOT_EQUALS: + return not _values_equal(actual, condition.value) + if condition.operator is AutomationConditionOperator.CONTAINS: + return _contains(actual, condition.value) + if condition.operator is AutomationConditionOperator.IN: + expected_values = condition.value + if not isinstance(expected_values, (list, tuple, set, frozenset)): + return False + if isinstance(actual, Sequence) and not isinstance( + actual, (str, bytes, bytearray) + ): + return any(item in expected_values for item in actual) + return actual in expected_values + actual_number = _decimal_or_none(actual) + expected_number = _decimal_or_none(condition.value) + if actual_number is None or expected_number is None: + return False + if condition.operator is AutomationConditionOperator.GREATER_THAN_OR_EQUAL: + return actual_number >= expected_number + if condition.operator is AutomationConditionOperator.LESS_THAN_OR_EQUAL: + return actual_number <= expected_number + return False + + +def _values_equal(actual: Any, expected: Any) -> bool: + """Compare JSON values while accepting equivalent finite numeric values. + + Rule payloads arrive through JSON, so an operator may enter ``1250`` while + a bank fact is stored as the canonical decimal string ``"1250.00"``. Keep + nonnumeric comparisons exact, but compare two finite numeric values as + ``Decimal`` so the typed rule contract is not accidentally format-bound. + """ + actual_number = _decimal_or_none(actual) + expected_number = _decimal_or_none(expected) + if actual_number is not None and expected_number is not None: + return actual_number == expected_number + return actual == expected + + +def _disposition_for_mode(mode: AutomationRuleMode) -> AutomationActionDisposition: + if mode is AutomationRuleMode.AUTOMATIC: + return AutomationActionDisposition.READY + if mode is AutomationRuleMode.SUGGEST: + return AutomationActionDisposition.SUGGESTED + return AutomationActionDisposition.OBSERVED + + +def evaluate_automation_rules( + event: AutomationEventInput, + rules: Sequence[AutomationRule], + *, + allowed_fact_paths: set[str] | frozenset[str] | None = None, + allowed_action_types: set[str] | frozenset[str] | None = None, +) -> AutomationEvaluation: + """Evaluate applicable rules without executing any actions. + + The optional allowlists are a policy boundary owned by the caller. An + unregistered fact path or action type fails closed rather than making a + future event schema change silently alter an existing automation. + """ + traces: list[MatchedAutomationRule] = [] + proposals: list[AutomationActionProposal] = [] + applicable_rules = sorted( + ( + rule + for rule in rules + if rule.enabled and rule.event_type == event.event_type + ), + key=lambda rule: (-rule.priority, rule.id), + ) + + for rule in applicable_rules: + condition_traces: list[ConditionEvaluation] = [] + for condition in rule.conditions: + if ( + allowed_fact_paths is not None + and condition.fact not in allowed_fact_paths + ): + condition_traces.append( + ConditionEvaluation( + fact=condition.fact, + operator=condition.operator, + matched=False, + reason="fact_path_not_allowed", + ) + ) + continue + actual = resolve_fact_path(event.facts, condition.fact) + matched = _condition_matches(condition, actual) + condition_traces.append( + ConditionEvaluation( + fact=condition.fact, + operator=condition.operator, + matched=matched, + reason=("fact_missing" if actual is _MISSING else None), + ) + ) + + matched_rule = all(trace.matched for trace in condition_traces) + traces.append( + MatchedAutomationRule( + rule_id=rule.id, + rule_version=rule.version, + project_id=rule.project_id, + priority=rule.priority, + mode=rule.mode, + matched=matched_rule, + conditions=condition_traces, + rule_snapshot=rule.model_dump(mode="json"), + ) + ) + if not matched_rule: + continue + + for action in rule.actions: + if ( + allowed_action_types is not None + and action.action_type not in allowed_action_types + ): + continue + proposals.append( + AutomationActionProposal( + event_id=event.event_id, + rule_id=rule.id, + rule_version=rule.version, + rule_project_id=rule.project_id, + rule_priority=rule.priority, + mode=rule.mode, + disposition=_disposition_for_mode(rule.mode), + action=action, + ) + ) + + return AutomationEvaluation( + event_id=event.event_id, + event_type=event.event_type, + rules=traces, + action_proposals=proposals, + ) diff --git a/packages/shared/src/five08/automation_store.py b/packages/shared/src/five08/automation_store.py new file mode 100644 index 00000000..d11b9473 --- /dev/null +++ b/packages/shared/src/five08/automation_store.py @@ -0,0 +1,1295 @@ +"""Durable storage for typed automation events, rules, and action proposals.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum +from typing import Any +from uuid import UUID, uuid4 + +from psycopg.rows import dict_row +from psycopg.types.json import Jsonb + +from five08.automation import ( + AutomationActionDisposition, + AutomationEvaluation, + AutomationEventInput, + AutomationReviewDecision, + AutomationRule, + AutomationRuleOrigin, +) +from five08.queue import get_postgres_connection +from five08.settings import SharedSettings + + +class AutomationActionStatus(StrEnum): + """Lifecycle states for an action proposal's durable side effect.""" + + PENDING = "pending" + AWAITING_REVIEW = "awaiting_review" + APPROVED = "approved" + RUNNING = "running" + SUCCEEDED = "succeeded" + FAILED = "failed" + DEAD = "dead" + + +_AUTOMATION_ACTION_MAX_ATTEMPTS = 8 +_AUTOMATION_ACTION_RETRY_BASE_SECONDS = 15 +_AUTOMATION_ACTION_RETRY_MAX_SECONDS = 3600 + + +@dataclass(frozen=True) +class StoredAutomationEvent: + """Persistent event inbox row.""" + + id: str + created: bool + + +@dataclass(frozen=True) +class StoredAutomationAction: + """One action proposal loaded from the durable semantic outbox.""" + + id: str + event_id: str + action_type: str + payload: dict[str, Any] + mode: str + disposition: str + status: AutomationActionStatus + attempts: int + idempotency_key: str + lease_token: str | None + approved_by: str | None + rule_project_id: str | None + review_decision: AutomationReviewDecision | None = None + reviewed_by: str | None = None + reviewed_at: datetime | None = None + + +@dataclass(frozen=True) +class StoredAutomationReviewAction: + """A human-reviewable action with its immutable payment event evidence.""" + + action: StoredAutomationAction + subject_id: str + subject_snapshot: dict[str, Any] + + +@dataclass(frozen=True) +class StoredAutomationRule: + """One dashboard-manageable rule plus its immutable creator attribution.""" + + rule: AutomationRule + created_by: str | None + + +def _as_stored_review_action(row: dict[str, Any]) -> StoredAutomationReviewAction: + """Decode a review row while preserving only immutable subject evidence.""" + snapshot = row.get("subject_snapshot") + return StoredAutomationReviewAction( + action=_as_stored_action(row), + subject_id=str(row["subject_id"]), + subject_snapshot=dict(snapshot) if isinstance(snapshot, dict) else {}, + ) + + +def _as_action_status(value: object) -> AutomationActionStatus: + try: + return AutomationActionStatus(str(value)) + except ValueError as exc: + raise ValueError(f"Unknown automation action status: {value!r}") from exc + + +def _as_review_decision(value: object) -> AutomationReviewDecision | None: + normalized = str(value or "").strip() + if not normalized: + return None + try: + return AutomationReviewDecision(normalized) + except ValueError as exc: + raise ValueError(f"Unknown automation review decision: {value!r}") from exc + + +def _as_stored_action(row: dict[str, Any]) -> StoredAutomationAction: + payload = row.get("payload") + return StoredAutomationAction( + id=str(row["id"]), + event_id=str(row["event_id"]), + action_type=str(row["action_type"]), + payload=dict(payload) if isinstance(payload, dict) else {}, + mode=str(row["mode"]), + disposition=str(row["disposition"]), + status=_as_action_status(row["status"]), + attempts=int(row["attempts"]), + idempotency_key=str(row["idempotency_key"]), + lease_token=str(row.get("lease_token") or "").strip() or None, + approved_by=str(row.get("approved_by") or "").strip() or None, + rule_project_id=( + str(row["rule_project_id"]) + if row.get("rule_project_id") is not None + else None + ), + review_decision=_as_review_decision(row.get("review_decision")), + reviewed_by=str(row.get("reviewed_by") or "").strip() or None, + reviewed_at=( + row.get("reviewed_at") + if isinstance(row.get("reviewed_at"), datetime) + else None + ), + ) + + +def _as_stored_rule(row: dict[str, Any]) -> StoredAutomationRule: + """Decode a rule row through the same strict Pydantic contract as runtime.""" + conditions = row.get("conditions") + actions = row.get("actions") + rule = AutomationRule.model_validate( + { + "id": str(row["id"]), + "project_id": row.get("project_id"), + "event_type": row["event_type"], + "origin": row.get("origin") or AutomationRuleOrigin.CONFIGURED.value, + "learning_key": row.get("learning_key"), + "priority": row["priority"], + "mode": row["mode"], + "enabled": row["enabled"], + "version": row["version"], + "conditions": conditions if isinstance(conditions, list) else [], + "actions": actions if isinstance(actions, list) else [], + } + ) + created_by = str(row.get("created_by") or "").strip() or None + return StoredAutomationRule(rule=rule, created_by=created_by) + + +def _rule_database_values(rule: AutomationRule) -> tuple[Any, ...]: + """Normalize a typed rule into the JSON/UUID values expected by Postgres.""" + try: + rule_id = str(UUID(rule.id)) + except (AttributeError, TypeError, ValueError) as exc: + raise ValueError("rule id must be a UUID") from exc + project_id: str | None = None + if rule.project_id is not None: + try: + project_id = str(UUID(rule.project_id)) + except (AttributeError, TypeError, ValueError) as exc: + raise ValueError("project_id must be a UUID") from exc + return ( + rule_id, + project_id, + rule.event_type, + rule.origin.value, + rule.learning_key, + rule.priority, + rule.mode.value, + rule.enabled, + rule.version, + Jsonb([condition.model_dump(mode="json") for condition in rule.conditions]), + Jsonb([action.model_dump(mode="json") for action in rule.actions]), + ) + + +def _insert_automation_event( + cursor: Any, + *, + event: AutomationEventInput, + event_key: str, +) -> StoredAutomationEvent: + """Insert/load an inbox event using the caller's active transaction.""" + normalized_key = event_key.strip() + if not normalized_key: + raise ValueError("event_key is required") + event_id = str(uuid4()) + cursor.execute( + """ + INSERT INTO automation_events ( + id, + event_key, + event_type, + source, + subject_id, + subject_revision, + subject_snapshot, + occurred_at, + facts + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (event_key) DO NOTHING + RETURNING id + """, + ( + event_id, + normalized_key, + event.event_type, + event.source, + event.subject_id, + event.subject_revision, + Jsonb(event.subject_snapshot), + event.occurred_at, + Jsonb(event.facts), + ), + ) + row = cursor.fetchone() + if row is not None: + return StoredAutomationEvent(id=str(row["id"]), created=True) + cursor.execute( + "SELECT id FROM automation_events WHERE event_key = %s", + (normalized_key,), + ) + existing = cursor.fetchone() + if existing is None: + raise RuntimeError("Unable to load duplicate automation event") + return StoredAutomationEvent(id=str(existing["id"]), created=False) + + +def upsert_automation_event( + settings: SharedSettings, + *, + event: AutomationEventInput, + event_key: str, +) -> StoredAutomationEvent: + """Write an event inbox row exactly once for a caller-defined dedupe key.""" + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + return _insert_automation_event(cursor, event=event, event_key=event_key) + + +def list_enabled_automation_rules( + settings: SharedSettings, + *, + event_type: str, +) -> list[AutomationRule]: + """Load enabled, versioned rules for one event type in evaluation order.""" + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT id::text, event_type, origin, learning_key, priority, mode, + enabled, version, project_id::text, conditions, actions, created_by + FROM automation_rules + WHERE event_type = %s AND enabled IS TRUE + ORDER BY priority DESC, id ASC + """, + (event_type,), + ) + rows = cursor.fetchall() + return [_as_stored_rule(row).rule for row in rows] + + +def list_automation_rules( + settings: SharedSettings, + *, + event_type: str | None = None, + project_id: str | None = None, +) -> list[StoredAutomationRule]: + """List persisted rules for the protected operator configuration surface.""" + normalized_event_type = str(event_type or "").strip() or None + normalized_project_id: str | None = None + if project_id is not None: + try: + normalized_project_id = str(UUID(str(project_id))) + except (AttributeError, TypeError, ValueError) as exc: + raise ValueError("project_id must be a UUID") from exc + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT id::text, event_type, origin, learning_key, priority, mode, + enabled, version, project_id::text, conditions, actions, created_by + FROM automation_rules + WHERE (%s::text IS NULL OR event_type = %s) + AND (%s::uuid IS NULL OR project_id = %s::uuid) + ORDER BY priority DESC, id ASC + """, + ( + normalized_event_type, + normalized_event_type, + normalized_project_id, + normalized_project_id, + ), + ) + rows = cursor.fetchall() + return [_as_stored_rule(row) for row in rows] + + +def create_automation_rule( + settings: SharedSettings, + *, + rule: AutomationRule, + created_by: str | None, +) -> StoredAutomationRule: + """Persist a new versioned typed rule after API-side policy validation.""" + ( + rule_id, + project_id, + event_type, + origin, + learning_key, + priority, + mode, + enabled, + version, + conditions, + actions, + ) = _rule_database_values(rule) + normalized_created_by = str(created_by or "").strip() or None + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + INSERT INTO automation_rules ( + id, + project_id, + event_type, + origin, + learning_key, + priority, + mode, + enabled, + version, + conditions, + actions, + created_by + ) VALUES ( + %s::uuid, %s::uuid, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s + ) + RETURNING id::text, event_type, origin, learning_key, priority, + mode, enabled, version, project_id::text, conditions, + actions, created_by + """, + ( + rule_id, + project_id, + event_type, + origin, + learning_key, + priority, + mode, + enabled, + version, + conditions, + actions, + normalized_created_by, + ), + ) + row = cursor.fetchone() + if row is None: + raise RuntimeError("Unable to create automation rule") + return _as_stored_rule(row) + + +def create_automation_rule_if_absent( + settings: SharedSettings, + *, + rule: AutomationRule, + created_by: str | None, +) -> tuple[StoredAutomationRule, bool]: + """Create a feedback-derived rule once, or return its exact prior copy. + + The caller supplies a deterministic rule id and opaque learning key. Both + have unique constraints, so separate approval requests can safely race + without creating duplicate future suggestions. + """ + if rule.origin is not AutomationRuleOrigin.LEARNED: + raise ValueError("only learned rules may be created idempotently") + ( + rule_id, + project_id, + event_type, + origin, + learning_key, + priority, + mode, + enabled, + version, + conditions, + actions, + ) = _rule_database_values(rule) + normalized_created_by = str(created_by or "").strip() or None + existing_row: dict[str, Any] | None = None + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + INSERT INTO automation_rules ( + id, + project_id, + event_type, + origin, + learning_key, + priority, + mode, + enabled, + version, + conditions, + actions, + created_by + ) VALUES ( + %s::uuid, %s::uuid, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s + ) + ON CONFLICT DO NOTHING + RETURNING id::text, event_type, origin, learning_key, priority, + mode, enabled, version, project_id::text, conditions, + actions, created_by + """, + ( + rule_id, + project_id, + event_type, + origin, + learning_key, + priority, + mode, + enabled, + version, + conditions, + actions, + normalized_created_by, + ), + ) + row = cursor.fetchone() + if row is not None: + return _as_stored_rule(row), True + cursor.execute( + """ + SELECT id::text, event_type, origin, learning_key, priority, mode, + enabled, version, project_id::text, conditions, actions, + created_by + FROM automation_rules + WHERE id = %s::uuid OR learning_key = %s + ORDER BY (id = %s::uuid) DESC + LIMIT 1 + """, + (rule_id, learning_key, rule_id), + ) + existing_row = cursor.fetchone() + if existing_row is None: + raise RuntimeError("Unable to load duplicate automation rule") + stored_rule = _as_stored_rule(existing_row) + if stored_rule.rule != rule: + raise ValueError("automation_rule_id_or_learning_key_conflict") + return stored_rule, False + + +def update_automation_rule( + settings: SharedSettings, + *, + rule: AutomationRule, + expected_version: int, +) -> StoredAutomationRule | None: + """Replace an operator-configured rule only when its version is current. + + Learned rules are intentionally immutable apart from disablement: editing + their predicate would erase the exact feedback-derived provenance while + making it appear that a human configured the result. + """ + if expected_version < 1: + raise ValueError("expected_version must be at least 1") + ( + rule_id, + project_id, + event_type, + _origin, + _learning_key, + priority, + mode, + enabled, + _version, + conditions, + actions, + ) = _rule_database_values(rule) + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + UPDATE automation_rules + SET project_id = %s::uuid, + event_type = %s, + priority = %s, + mode = %s, + enabled = %s, + version = version + 1, + conditions = %s, + actions = %s, + updated_at = NOW() + WHERE id = %s::uuid + AND version = %s + AND origin = %s + RETURNING id::text, event_type, origin, learning_key, priority, + mode, enabled, version, project_id::text, conditions, + actions, created_by + """, + ( + project_id, + event_type, + priority, + mode, + enabled, + conditions, + actions, + rule_id, + expected_version, + AutomationRuleOrigin.CONFIGURED.value, + ), + ) + row = cursor.fetchone() + if row is not None: + _invalidate_pending_automatic_actions( + cursor, + rule_id=rule_id, + reason="rule_version_superseded", + ) + return _as_stored_rule(row) if row is not None else None + + +def disable_automation_rule( + settings: SharedSettings, + *, + rule_id: str, + expected_version: int, +) -> StoredAutomationRule | None: + """Soft-disable a rule while preserving its evaluation/audit history.""" + if expected_version < 1: + raise ValueError("expected_version must be at least 1") + try: + normalized_rule_id = str(UUID(rule_id)) + except (AttributeError, TypeError, ValueError) as exc: + raise ValueError("rule_id must be a UUID") from exc + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + UPDATE automation_rules + SET enabled = FALSE, + version = version + 1, + updated_at = NOW() + WHERE id = %s::uuid + AND version = %s + RETURNING id::text, event_type, origin, learning_key, priority, + mode, enabled, version, project_id::text, conditions, + actions, created_by + """, + (normalized_rule_id, expected_version), + ) + row = cursor.fetchone() + if row is not None: + _invalidate_pending_automatic_actions( + cursor, + rule_id=normalized_rule_id, + reason="rule_disabled", + ) + return _as_stored_rule(row) if row is not None else None + + +def _invalidate_pending_automatic_actions( + cursor: Any, + *, + rule_id: str, + reason: str, +) -> None: + """Fence queued automatic side effects after an operator changes a rule.""" + cursor.execute( + """ + UPDATE automation_actions action + SET status = %s, + last_error = %s, + locked_at = NULL, + lease_token = NULL, + next_attempt_at = NULL, + updated_at = NOW() + FROM automation_rule_evaluations evaluation + WHERE action.rule_evaluation_id = evaluation.id + AND evaluation.rule_id = %s::uuid + AND action.mode = 'automatic' + AND action.status IN (%s, %s) + """, + ( + AutomationActionStatus.DEAD.value, + reason, + rule_id, + AutomationActionStatus.PENDING.value, + AutomationActionStatus.FAILED.value, + ), + ) + + +def _persist_automation_evaluation( + cursor: Any, + *, + stored_event_id: str, + evaluation: AutomationEvaluation, +) -> list[str]: + """Persist a fully evaluated event through the caller's transaction.""" + action_ids: list[str] = [] + proposals_by_rule: dict[tuple[str, int], list[tuple[int, Any]]] = {} + for index, proposal in enumerate(evaluation.action_proposals): + proposals_by_rule.setdefault( + (proposal.rule_id, proposal.rule_version), [] + ).append((index, proposal)) + + for trace in evaluation.rules: + evaluation_id = str(uuid4()) + cursor.execute( + """ + INSERT INTO automation_rule_evaluations ( + id, + event_id, + rule_id, + rule_version, + rule_project_id, + matched, + condition_trace, + rule_snapshot + ) VALUES (%s, %s::uuid, %s::uuid, %s, %s::uuid, %s, %s, %s) + ON CONFLICT (event_id, rule_id, rule_version) DO UPDATE + SET matched = EXCLUDED.matched, + condition_trace = EXCLUDED.condition_trace, + rule_snapshot = EXCLUDED.rule_snapshot, + evaluated_at = NOW() + RETURNING id + """, + ( + evaluation_id, + stored_event_id, + trace.rule_id, + trace.rule_version, + trace.project_id, + trace.matched, + Jsonb( + [ + condition.model_dump(mode="json") + for condition in trace.conditions + ] + ), + Jsonb(trace.rule_snapshot), + ), + ) + evaluation_row = cursor.fetchone() + if evaluation_row is None: + raise RuntimeError("Unable to persist automation rule evaluation") + persisted_evaluation_id = str(evaluation_row["id"]) + for position, proposal in proposals_by_rule.get( + (trace.rule_id, trace.rule_version), [] + ): + action_id = str(uuid4()) + idempotency_key = ( + f"automation:v1:{stored_event_id}:{trace.rule_id}:" + f"{trace.rule_version}:{position}" + ) + initial_status = ( + AutomationActionStatus.PENDING + if proposal.disposition is AutomationActionDisposition.READY + else ( + AutomationActionStatus.AWAITING_REVIEW + if proposal.disposition is AutomationActionDisposition.SUGGESTED + else AutomationActionStatus.DEAD + ) + ) + cursor.execute( + """ + INSERT INTO automation_actions ( + id, + event_id, + rule_evaluation_id, + action_type, + payload, + mode, + disposition, + status, + idempotency_key + ) VALUES (%s, %s::uuid, %s::uuid, %s, %s, %s, %s, %s, %s) + ON CONFLICT (idempotency_key) DO NOTHING + RETURNING id + """, + ( + action_id, + stored_event_id, + persisted_evaluation_id, + proposal.action.action_type, + Jsonb(proposal.action.payload), + proposal.mode.value, + proposal.disposition.value, + initial_status.value, + idempotency_key, + ), + ) + action_row = cursor.fetchone() + if action_row is not None: + action_ids.append(str(action_row["id"])) + return action_ids + + +def persist_automation_evaluation( + settings: SharedSettings, + *, + stored_event_id: str, + evaluation: AutomationEvaluation, +) -> list[str]: + """Persist rule traces and action proposals in one database transaction.""" + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + return _persist_automation_evaluation( + cursor, + stored_event_id=stored_event_id, + evaluation=evaluation, + ) + + +def persist_automation_event_and_evaluation( + settings: SharedSettings, + *, + event: AutomationEventInput, + event_key: str, + evaluation: AutomationEvaluation, +) -> tuple[StoredAutomationEvent, list[str]]: + """Atomically persist a new inbox event with its semantic action outbox. + + A process crash cannot leave a deduped event that has no evaluation: the + event row, traces, and proposed actions commit together or roll back + together. Duplicate event keys simply return the existing event and let the + caller ask for genuinely retryable actions. + """ + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + stored_event = _insert_automation_event( + cursor, + event=event, + event_key=event_key, + ) + if not stored_event.created: + return stored_event, [] + action_ids = _persist_automation_evaluation( + cursor, + stored_event_id=stored_event.id, + evaluation=evaluation, + ) + return stored_event, action_ids + + +def get_automation_action( + settings: SharedSettings, + *, + action_id: str, +) -> StoredAutomationAction | None: + """Load a single semantic action from the durable outbox.""" + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT action.id::text, action.event_id::text, action.action_type, + action.payload, action.mode, action.disposition, + action.status, action.attempts, action.idempotency_key, + action.lease_token::text, action.approved_by, + action.review_decision, action.reviewed_by, action.reviewed_at, + evaluation.rule_project_id::text + FROM automation_actions action + INNER JOIN automation_rule_evaluations evaluation + ON evaluation.id = action.rule_evaluation_id + WHERE action.id = %s::uuid + """, + (action_id,), + ) + row = cursor.fetchone() + return _as_stored_action(row) if row is not None else None + + +def claim_automation_action( + settings: SharedSettings, + *, + action_id: str, + stale_after_seconds: int = 300, +) -> StoredAutomationAction | None: + """Atomically claim due work with an ownership token for finalization.""" + lease_token = str(uuid4()) + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + UPDATE automation_actions + SET status = %s, + attempts = attempts + 1, + locked_at = NOW(), + lease_token = %s::uuid, + updated_at = NOW() + WHERE id = %s::uuid + AND ( + status IN (%s, %s) + OR ( + status = %s + AND (next_attempt_at IS NULL OR next_attempt_at <= NOW()) + ) + OR ( + status = %s + AND locked_at < NOW() - (%s * INTERVAL '1 second') + ) + ) + RETURNING id::text, event_id::text, action_type, payload, mode, + disposition, status, attempts, idempotency_key, + lease_token::text, approved_by, + review_decision, reviewed_by, reviewed_at, + ( + SELECT rule_project_id::text + FROM automation_rule_evaluations + WHERE id = automation_actions.rule_evaluation_id + ) AS rule_project_id + """, + ( + AutomationActionStatus.RUNNING.value, + lease_token, + action_id, + AutomationActionStatus.PENDING.value, + AutomationActionStatus.APPROVED.value, + AutomationActionStatus.FAILED.value, + AutomationActionStatus.RUNNING.value, + max(1, stale_after_seconds), + ), + ) + row = cursor.fetchone() + return _as_stored_action(row) if row is not None else None + + +def mark_automation_action_succeeded( + settings: SharedSettings, + *, + action_id: str, + result: dict[str, Any], + lease_token: str, +) -> bool: + """Record a completed action only if this worker still owns its lease.""" + with get_postgres_connection(settings) as conn: + with conn.cursor() as cursor: + cursor.execute( + """ + UPDATE automation_actions + SET status = %s, + result = %s, + last_error = NULL, + locked_at = NULL, + lease_token = NULL, + next_attempt_at = NULL, + updated_at = NOW() + WHERE id = %s::uuid + AND status = %s + AND lease_token = %s::uuid + RETURNING id + """, + ( + AutomationActionStatus.SUCCEEDED.value, + Jsonb(result), + action_id, + AutomationActionStatus.RUNNING.value, + lease_token, + ), + ) + return cursor.fetchone() is not None + + +def mark_automation_action_failed( + settings: SharedSettings, + *, + action_id: str, + error: str, + dead: bool = False, + lease_token: str, +) -> bool: + """Record an owned action failure with bounded backoff and a dead policy.""" + normalized_error = error.strip()[:2000] or "automation action failed" + with get_postgres_connection(settings) as conn: + with conn.cursor() as cursor: + cursor.execute( + """ + UPDATE automation_actions + SET status = CASE + WHEN %s OR attempts >= %s THEN %s + ELSE %s + END, + last_error = %s, + locked_at = NULL, + lease_token = NULL, + next_attempt_at = CASE + WHEN %s OR attempts >= %s THEN NULL + ELSE NOW() + ( + LEAST( + %s, + %s * POWER(2, GREATEST(attempts - 1, 0)) + ) * INTERVAL '1 second' + ) + END, + updated_at = NOW() + WHERE id = %s::uuid + AND status = %s + AND lease_token = %s::uuid + RETURNING id + """, + ( + dead, + _AUTOMATION_ACTION_MAX_ATTEMPTS, + AutomationActionStatus.DEAD.value, + AutomationActionStatus.FAILED.value, + normalized_error, + dead, + _AUTOMATION_ACTION_MAX_ATTEMPTS, + _AUTOMATION_ACTION_RETRY_MAX_SECONDS, + _AUTOMATION_ACTION_RETRY_BASE_SECONDS, + action_id, + AutomationActionStatus.RUNNING.value, + lease_token, + ), + ) + return cursor.fetchone() is not None + + +def list_pending_automation_action_ids( + settings: SharedSettings, + *, + limit: int = 100, + stale_after_seconds: int = 300, +) -> list[str]: + """List due actions, including leases stranded after a worker crash.""" + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT id::text + FROM automation_actions + WHERE status IN (%s, %s) + OR ( + status = %s + AND (next_attempt_at IS NULL OR next_attempt_at <= NOW()) + ) + OR ( + status = %s + AND locked_at < NOW() - (%s * INTERVAL '1 second') + ) + ORDER BY + CASE WHEN status IN (%s, %s) THEN 0 ELSE 1 END, + COALESCE(next_attempt_at, created_at) ASC, + created_at ASC + LIMIT %s + """, + ( + AutomationActionStatus.PENDING.value, + AutomationActionStatus.APPROVED.value, + AutomationActionStatus.FAILED.value, + AutomationActionStatus.RUNNING.value, + max(1, stale_after_seconds), + AutomationActionStatus.PENDING.value, + AutomationActionStatus.APPROVED.value, + max(1, min(limit, 1000)), + ), + ) + rows = cursor.fetchall() + return [str(row["id"]) for row in rows] + + +def list_retryable_automation_action_ids_for_event( + settings: SharedSettings, + *, + event_id: str, +) -> list[str]: + """Return automatic actions from one event that still need execution.""" + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT id::text + FROM automation_actions + WHERE event_id = %s::uuid + AND ( + status IN (%s, %s) + OR ( + status = %s + AND (next_attempt_at IS NULL OR next_attempt_at <= NOW()) + ) + ) + ORDER BY + CASE WHEN status IN (%s, %s) THEN 0 ELSE 1 END, + COALESCE(next_attempt_at, created_at) ASC, + created_at ASC + """, + ( + event_id, + AutomationActionStatus.PENDING.value, + AutomationActionStatus.APPROVED.value, + AutomationActionStatus.FAILED.value, + AutomationActionStatus.PENDING.value, + AutomationActionStatus.APPROVED.value, + ), + ) + rows = cursor.fetchall() + return [str(row["id"]) for row in rows] + + +def automation_action_subject_id( + settings: SharedSettings, + *, + action_id: str, +) -> str | None: + """Load the normalized event subject attached to an action proposal.""" + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT event.subject_id + FROM automation_actions action + INNER JOIN automation_events event ON event.id = action.event_id + WHERE action.id = %s::uuid + """, + (action_id,), + ) + row = cursor.fetchone() + return str(row["subject_id"]) if row is not None else None + + +def automation_action_rule_project_id( + settings: SharedSettings, + *, + action_id: str, +) -> str | None: + """Return the immutable project scope captured with the evaluation.""" + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT evaluation.rule_project_id::text AS project_id + FROM automation_actions action + INNER JOIN automation_rule_evaluations evaluation + ON evaluation.id = action.rule_evaluation_id + WHERE action.id = %s::uuid + """, + (action_id,), + ) + row = cursor.fetchone() + if row is None or row.get("project_id") is None: + return None + return str(row["project_id"]) + + +def list_automation_actions_awaiting_review( + settings: SharedSettings, + *, + event_type: str, + project_id: str | None = None, + limit: int = 100, +) -> list[StoredAutomationReviewAction]: + """List durable suggestions with the immutable evidence a human reviews.""" + normalized_project_id: str | None = None + if project_id is not None: + try: + normalized_project_id = str(UUID(project_id)) + except (AttributeError, TypeError, ValueError) as exc: + raise ValueError("project_id must be a UUID") from exc + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT action.id::text, action.event_id::text, action.action_type, + action.payload, action.mode, action.disposition, + action.status, action.attempts, action.idempotency_key, + action.lease_token::text, action.approved_by, + action.review_decision, action.reviewed_by, action.reviewed_at, + evaluation.rule_project_id::text, + event.subject_id, event.subject_snapshot + FROM automation_actions action + INNER JOIN automation_events event ON event.id = action.event_id + INNER JOIN automation_rule_evaluations evaluation + ON evaluation.id = action.rule_evaluation_id + WHERE event.event_type = %s + AND action.status = %s + AND (%s::uuid IS NULL OR evaluation.rule_project_id = %s::uuid) + ORDER BY action.created_at ASC + LIMIT %s + """, + ( + event_type, + AutomationActionStatus.AWAITING_REVIEW.value, + normalized_project_id, + normalized_project_id, + max(1, min(limit, 1000)), + ), + ) + rows = cursor.fetchall() + return [_as_stored_review_action(row) for row in rows] + + +def get_automation_review_action( + settings: SharedSettings, + *, + action_id: str, +) -> StoredAutomationReviewAction | None: + """Load the immutable evidence for one action after a review transition.""" + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT action.id::text, action.event_id::text, action.action_type, + action.payload, action.mode, action.disposition, + action.status, action.attempts, action.idempotency_key, + action.lease_token::text, action.approved_by, + action.review_decision, action.reviewed_by, action.reviewed_at, + evaluation.rule_project_id::text, + event.subject_id, event.subject_snapshot + FROM automation_actions action + INNER JOIN automation_events event ON event.id = action.event_id + INNER JOIN automation_rule_evaluations evaluation + ON evaluation.id = action.rule_evaluation_id + WHERE action.id = %s::uuid + """, + (action_id,), + ) + row = cursor.fetchone() + if row is None: + return None + return _as_stored_review_action(row) + + +def list_approved_automation_review_actions( + settings: SharedSettings, + *, + event_type: str, + action_type: str, + limit: int = 100, +) -> list[StoredAutomationReviewAction]: + """Return durable positive feedback for idempotent learning recovery. + + A direct approval should normally derive its rule immediately. This query + lets the periodic payment recovery sweep retry that derivation after a + transient database/API process failure without replaying the money action. + """ + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT action.id::text, action.event_id::text, action.action_type, + action.payload, action.mode, action.disposition, + action.status, action.attempts, action.idempotency_key, + action.lease_token::text, action.approved_by, + action.review_decision, action.reviewed_by, action.reviewed_at, + evaluation.rule_project_id::text, + event.subject_id, event.subject_snapshot + FROM automation_actions action + INNER JOIN automation_events event ON event.id = action.event_id + INNER JOIN automation_rule_evaluations evaluation + ON evaluation.id = action.rule_evaluation_id + WHERE event.event_type = %s + AND action.action_type = %s + AND action.disposition = %s + AND action.review_decision = %s + AND action.approved_by IS NOT NULL + ORDER BY action.reviewed_at DESC NULLS LAST, action.id ASC + LIMIT %s + """, + ( + event_type, + action_type, + AutomationActionDisposition.SUGGESTED.value, + AutomationReviewDecision.APPROVED.value, + max(1, min(limit, 1000)), + ), + ) + rows = cursor.fetchall() + return [_as_stored_review_action(row) for row in rows] + + +def approve_automation_action( + settings: SharedSettings, + *, + action_id: str, + approved_by: str, +) -> StoredAutomationAction | None: + """Turn one suggestion into an explicitly human-authorized side effect.""" + normalized_approver = approved_by.strip() + if not normalized_approver: + raise ValueError("approved_by is required") + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + UPDATE automation_actions + SET status = %s, + approved_by = %s, + approved_at = NOW(), + review_decision = %s, + reviewed_by = %s, + reviewed_at = NOW(), + next_attempt_at = NOW(), + last_error = NULL, + updated_at = NOW() + WHERE id = %s::uuid + AND status = %s + AND disposition = %s + RETURNING id::text, event_id::text, action_type, payload, mode, + disposition, status, attempts, idempotency_key, + lease_token::text, approved_by, + review_decision, reviewed_by, reviewed_at, + ( + SELECT rule_project_id::text + FROM automation_rule_evaluations + WHERE id = automation_actions.rule_evaluation_id + ) AS rule_project_id + """, + ( + AutomationActionStatus.APPROVED.value, + normalized_approver, + AutomationReviewDecision.APPROVED.value, + normalized_approver, + action_id, + AutomationActionStatus.AWAITING_REVIEW.value, + AutomationActionDisposition.SUGGESTED.value, + ), + ) + row = cursor.fetchone() + return _as_stored_action(row) if row is not None else None + + +def reject_automation_action( + settings: SharedSettings, + *, + action_id: str, + rejected_by: str, +) -> StoredAutomationAction | None: + """Record a human rejection without deleting the original suggestion.""" + normalized_reviewer = rejected_by.strip() + if not normalized_reviewer: + raise ValueError("rejected_by is required") + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + UPDATE automation_actions + SET status = %s, + review_decision = %s, + reviewed_by = %s, + reviewed_at = NOW(), + last_error = %s, + locked_at = NULL, + lease_token = NULL, + next_attempt_at = NULL, + updated_at = NOW() + WHERE id = %s::uuid + AND status = %s + AND disposition = %s + RETURNING id::text, event_id::text, action_type, payload, mode, + disposition, status, attempts, idempotency_key, + lease_token::text, approved_by, + review_decision, reviewed_by, reviewed_at, + ( + SELECT rule_project_id::text + FROM automation_rule_evaluations + WHERE id = automation_actions.rule_evaluation_id + ) AS rule_project_id + """, + ( + AutomationActionStatus.DEAD.value, + AutomationReviewDecision.REJECTED.value, + normalized_reviewer, + f"rejected_by:{normalized_reviewer}"[:2000], + action_id, + AutomationActionStatus.AWAITING_REVIEW.value, + AutomationActionDisposition.SUGGESTED.value, + ), + ) + row = cursor.fetchone() + return _as_stored_action(row) if row is not None else None diff --git a/packages/shared/src/five08/clients/discord_bot.py b/packages/shared/src/five08/clients/discord_bot.py index 00cd3f12..d6cbb093 100644 --- a/packages/shared/src/five08/clients/discord_bot.py +++ b/packages/shared/src/five08/clients/discord_bot.py @@ -102,6 +102,24 @@ def grant_member_role( payload, ) + def post_project_payment_notification( + self, + *, + notification_id: str, + lease_token: str, + ) -> dict[str, Any]: + """Ask the bot to load and post one canonical outbox notification. + + The bot derives project, channel, allocation, and money from Postgres. + ``lease_token`` is a short-lived worker capability, not client-provided + payment data: it proves the caller currently owns the sending outbox. + """ + return self.request( + "POST", + "internal/project-payments/notify", + {"notification_id": notification_id, "lease_token": lease_token}, + ) + def grant_member_role_for_signed_agreement( *, diff --git a/packages/shared/src/five08/clients/erpnext.py b/packages/shared/src/five08/clients/erpnext.py index 52db7762..ca0d665b 100644 --- a/packages/shared/src/five08/clients/erpnext.py +++ b/packages/shared/src/five08/clients/erpnext.py @@ -259,6 +259,10 @@ def get_record(self, doctype: str, record_id: str) -> dict[str, Any]: ) return row + def get_bank_transaction(self, transaction_name: str) -> dict[str, Any]: + """Read one canonical ERPNext Bank Transaction document.""" + return self.get_record("Bank Transaction", transaction_name) + def call_method( self, method: str, diff --git a/packages/shared/src/five08/project_discord_channels.py b/packages/shared/src/five08/project_discord_channels.py new file mode 100644 index 00000000..3eba02f3 --- /dev/null +++ b/packages/shared/src/five08/project_discord_channels.py @@ -0,0 +1,510 @@ +"""Persistence helpers for private Discord channels registered to ERP projects.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum +from typing import Any +from uuid import uuid4 + +from psycopg.rows import dict_row + +from five08.queue import get_postgres_connection +from five08.settings import SharedSettings + + +class ProjectDiscordChannelConflict(ValueError): + """Raised when a private channel is already actively owned by another project.""" + + +class ProjectPaymentDiscordDeliveryClaimStatus(StrEnum): + """Outcome of the bot-side lease for one notification ID.""" + + CLAIMED = "claimed" + ALREADY_DELIVERED = "already_delivered" + IN_PROGRESS = "in_progress" + SCOPE_MISMATCH = "scope_mismatch" + + +@dataclass(frozen=True) +class RegisteredProjectDiscordChannel: + """A verified private Discord target for project payment notifications.""" + + id: str + project_id: str + guild_id: str + channel_id: str + channel_name: str | None + active: bool + registered_by_discord_user_id: str | None + last_verified_at: datetime | None + last_verification_error: str | None + + +@dataclass(frozen=True) +class ProjectPaymentDiscordDeliveryClaim: + """The bot's durable delivery lease or an existing receipt.""" + + status: ProjectPaymentDiscordDeliveryClaimStatus + discord_message_id: str | None = None + lease_token: str | None = None + + +def _as_registered_channel(row: dict[str, Any]) -> RegisteredProjectDiscordChannel: + return RegisteredProjectDiscordChannel( + id=str(row["id"]), + project_id=str(row["project_id"]), + guild_id=str(row["guild_id"]), + channel_id=str(row["channel_id"]), + channel_name=(str(row["channel_name"]) if row.get("channel_name") else None), + active=bool(row["active"]), + registered_by_discord_user_id=( + str(row["registered_by_discord_user_id"]) + if row.get("registered_by_discord_user_id") + else None + ), + last_verified_at=row.get("last_verified_at"), + last_verification_error=( + str(row["last_verification_error"]) + if row.get("last_verification_error") + else None + ), + ) + + +def register_project_discord_channel( + settings: SharedSettings, + *, + project_id: str, + guild_id: str, + channel_id: str, + channel_name: str | None, + registered_by_discord_user_id: str | None, +) -> tuple[RegisteredProjectDiscordChannel, bool]: + """Activate one channel for a project without reassigning another project. + + Returns ``(channel, created)``. The partial unique index in the migration + guarantees a channel can have only one *active* project mapping. + """ + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + # The partial unique index is the serialization boundary. A plain + # SELECT ... FOR UPDATE cannot lock a row that does not exist, so + # it allows two first registrations to race into a raw unique error. + # At most one retry is needed if another request deactivated the + # winning mapping between our conflict and follow-up lookup. + for _ in range(2): + cursor.execute( + """ + INSERT INTO project_discord_channels ( + id, + project_id, + guild_id, + channel_id, + channel_name, + active, + registered_by_discord_user_id, + last_verified_at + ) VALUES (%s, %s::uuid, %s, %s, %s, TRUE, %s, NOW()) + ON CONFLICT (guild_id, channel_id) WHERE active IS TRUE + DO NOTHING + RETURNING id::text, project_id::text, guild_id, channel_id, + channel_name, active, registered_by_discord_user_id, + last_verified_at, last_verification_error + """, + ( + str(uuid4()), + project_id, + guild_id, + channel_id, + channel_name, + registered_by_discord_user_id, + ), + ) + created = cursor.fetchone() + if created is not None: + return _as_registered_channel(created), True + + cursor.execute( + """ + SELECT id::text, project_id::text, guild_id, channel_id, channel_name, + active, registered_by_discord_user_id, last_verified_at, + last_verification_error + FROM project_discord_channels + WHERE guild_id = %s AND channel_id = %s AND active IS TRUE + FOR UPDATE + """, + (guild_id, channel_id), + ) + active_mapping = cursor.fetchone() + if active_mapping is None: + continue + if str(active_mapping["project_id"]) != project_id: + raise ProjectDiscordChannelConflict( + "This Discord channel is already registered to another project" + ) + cursor.execute( + """ + UPDATE project_discord_channels + SET channel_name = %s, + registered_by_discord_user_id = %s, + last_verified_at = NOW(), + last_verification_error = NULL, + updated_at = NOW() + WHERE id = %s::uuid + RETURNING id::text, project_id::text, guild_id, channel_id, + channel_name, active, registered_by_discord_user_id, + last_verified_at, last_verification_error + """, + ( + channel_name, + registered_by_discord_user_id, + str(active_mapping["id"]), + ), + ) + updated = cursor.fetchone() + if updated is None: + raise RuntimeError("Unable to update project Discord channel") + return _as_registered_channel(updated), False + raise RuntimeError("Unable to register project Discord channel") + + +def claim_project_payment_discord_delivery( + settings: SharedSettings, + *, + notification_id: str, + project_discord_channel_id: str, + worker_lease_token: str, + stale_after_seconds: int = 300, +) -> ProjectPaymentDiscordDeliveryClaim: + """Claim bot-side delivery or return an existing receipt without sending. + + The worker's outbox and the bot are separate processes. This lease avoids + duplicate Discord sends when an internal HTTP request is retried after a + timeout, while the hidden message marker remains recovery for the tiny + Discord-send/receipt-write crash window. + """ + normalized_stale_after_seconds = max(1, stale_after_seconds) + lease_token = str(uuid4()) + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + INSERT INTO project_payment_discord_deliveries ( + notification_id, + project_discord_channel_id, + project_id, + channel_id, + status, + attempts, + locked_at, + lock_token + ) + SELECT outbox.id, + channel.id, + channel.project_id, + channel.channel_id, + 'sending', + 1, + NOW(), + %s::uuid + FROM project_payment_notification_outbox outbox + INNER JOIN project_discord_channels channel + ON channel.id = outbox.project_discord_channel_id + INNER JOIN projects project ON project.id = channel.project_id + WHERE outbox.id = %s::uuid + AND channel.id = %s::uuid + AND outbox.status = 'sending' + AND outbox.lease_token = %s::uuid + AND channel.active IS TRUE + AND LOWER(COALESCE(project.source_status, '')) = 'open' + ON CONFLICT (notification_id) DO NOTHING + RETURNING notification_id + """, + ( + lease_token, + notification_id, + project_discord_channel_id, + worker_lease_token, + ), + ) + if cursor.fetchone() is not None: + return ProjectPaymentDiscordDeliveryClaim( + status=ProjectPaymentDiscordDeliveryClaimStatus.CLAIMED, + lease_token=lease_token, + ) + + cursor.execute( + """ + UPDATE project_payment_discord_deliveries + SET status = 'sending', + attempts = attempts + 1, + locked_at = NOW(), + lock_token = %s::uuid, + last_error = NULL, + updated_at = NOW() + FROM project_payment_notification_outbox outbox + INNER JOIN project_discord_channels channel + ON channel.id = outbox.project_discord_channel_id + INNER JOIN projects project ON project.id = channel.project_id + WHERE project_payment_discord_deliveries.notification_id = outbox.id + AND project_payment_discord_deliveries.notification_id = %s::uuid + AND project_payment_discord_deliveries.project_discord_channel_id + = %s::uuid + AND channel.id = project_payment_discord_deliveries.project_discord_channel_id + AND outbox.status = 'sending' + AND outbox.lease_token = %s::uuid + AND channel.active IS TRUE + AND LOWER(COALESCE(project.source_status, '')) = 'open' + AND ( + project_payment_discord_deliveries.status = 'failed' + OR ( + project_payment_discord_deliveries.status = 'sending' + AND project_payment_discord_deliveries.locked_at + < NOW() - (%s * INTERVAL '1 second') + ) + ) + RETURNING notification_id + """, + ( + lease_token, + notification_id, + project_discord_channel_id, + worker_lease_token, + normalized_stale_after_seconds, + ), + ) + if cursor.fetchone() is not None: + return ProjectPaymentDiscordDeliveryClaim( + status=ProjectPaymentDiscordDeliveryClaimStatus.CLAIMED, + lease_token=lease_token, + ) + + cursor.execute( + """ + SELECT delivery.project_discord_channel_id::text, + delivery.status, + delivery.discord_message_id + FROM project_payment_discord_deliveries delivery + INNER JOIN project_payment_notification_outbox outbox + ON outbox.id = delivery.notification_id + WHERE delivery.notification_id = %s::uuid + AND outbox.status = 'sending' + AND outbox.lease_token = %s::uuid + """, + (notification_id, worker_lease_token), + ) + existing = cursor.fetchone() + if existing is None or ( + str(existing["project_discord_channel_id"]) != project_discord_channel_id + ): + return ProjectPaymentDiscordDeliveryClaim( + status=ProjectPaymentDiscordDeliveryClaimStatus.SCOPE_MISMATCH + ) + message_id = str(existing.get("discord_message_id") or "").strip() or None + if str(existing["status"]) == "sent" and message_id is not None: + return ProjectPaymentDiscordDeliveryClaim( + status=ProjectPaymentDiscordDeliveryClaimStatus.ALREADY_DELIVERED, + discord_message_id=message_id, + ) + return ProjectPaymentDiscordDeliveryClaim( + status=ProjectPaymentDiscordDeliveryClaimStatus.IN_PROGRESS + ) + + +def mark_project_payment_discord_delivery_sent( + settings: SharedSettings, + *, + notification_id: str, + project_discord_channel_id: str, + discord_message_id: str, + lease_token: str, +) -> bool: + """Persist the Discord receipt before returning success to the worker.""" + with get_postgres_connection(settings) as conn: + with conn.cursor() as cursor: + cursor.execute( + """ + UPDATE project_payment_discord_deliveries + SET status = 'sent', + discord_message_id = %s, + locked_at = NULL, + last_error = NULL, + updated_at = NOW() + WHERE notification_id = %s::uuid + AND project_discord_channel_id = %s::uuid + AND status = 'sending' + AND lock_token = %s::uuid + RETURNING notification_id + """, + ( + discord_message_id, + notification_id, + project_discord_channel_id, + lease_token, + ), + ) + return cursor.fetchone() is not None + + +def mark_project_payment_discord_delivery_failed( + settings: SharedSettings, + *, + notification_id: str, + project_discord_channel_id: str, + error: str, + lease_token: str, +) -> bool: + """Release a bot-side lease for a retry with bounded diagnostics.""" + normalized_error = error.strip()[:2000] or "project payment Discord delivery failed" + with get_postgres_connection(settings) as conn: + with conn.cursor() as cursor: + cursor.execute( + """ + UPDATE project_payment_discord_deliveries + SET status = 'failed', + locked_at = NULL, + last_error = %s, + updated_at = NOW() + WHERE notification_id = %s::uuid + AND project_discord_channel_id = %s::uuid + AND status = 'sending' + AND lock_token = %s::uuid + RETURNING notification_id + """, + ( + normalized_error, + notification_id, + project_discord_channel_id, + lease_token, + ), + ) + return cursor.fetchone() is not None + + +def renew_project_payment_discord_delivery_lease( + settings: SharedSettings, + *, + notification_id: str, + project_discord_channel_id: str, + worker_lease_token: str, + lease_token: str, +) -> bool: + """Extend an active bot lease while its worker still owns the outbox row.""" + with get_postgres_connection(settings) as conn: + with conn.cursor() as cursor: + cursor.execute( + """ + UPDATE project_payment_discord_deliveries + SET locked_at = NOW(), updated_at = NOW() + FROM project_payment_notification_outbox outbox + WHERE project_payment_discord_deliveries.notification_id = outbox.id + AND project_payment_discord_deliveries.notification_id = %s::uuid + AND project_payment_discord_deliveries.project_discord_channel_id + = %s::uuid + AND project_payment_discord_deliveries.status = 'sending' + AND project_payment_discord_deliveries.lock_token = %s::uuid + AND outbox.status = 'sending' + AND outbox.lease_token = %s::uuid + RETURNING notification_id + """, + ( + notification_id, + project_discord_channel_id, + lease_token, + worker_lease_token, + ), + ) + return cursor.fetchone() is not None + + +def unregister_project_discord_channel( + settings: SharedSettings, + *, + guild_id: str, + channel_id: str, +) -> bool: + """Deactivate a channel mapping while preserving the audit/history row.""" + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + UPDATE project_discord_channels + SET active = FALSE, updated_at = NOW() + WHERE guild_id = %s AND channel_id = %s AND active IS TRUE + RETURNING id + """, + (guild_id, channel_id), + ) + row = cursor.fetchone() + return row is not None + + +def list_active_project_discord_channels( + settings: SharedSettings, + *, + project_id: str, +) -> list[RegisteredProjectDiscordChannel]: + """Return active notification channels for one project.""" + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT id::text, project_id::text, guild_id, channel_id, channel_name, + active, registered_by_discord_user_id, last_verified_at, + last_verification_error + FROM project_discord_channels + WHERE project_id = %s::uuid AND active IS TRUE + ORDER BY created_at ASC + """, + (project_id,), + ) + rows = cursor.fetchall() + return [_as_registered_channel(row) for row in rows] + + +def get_active_project_discord_channel( + settings: SharedSettings, + *, + project_id: str, + channel_id: str, +) -> RegisteredProjectDiscordChannel | None: + """Load exactly one active mapping, for live bot delivery revalidation.""" + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT id::text, project_id::text, guild_id, channel_id, channel_name, + active, registered_by_discord_user_id, last_verified_at, + last_verification_error + FROM project_discord_channels + WHERE project_id = %s::uuid + AND channel_id = %s + AND active IS TRUE + """, + (project_id, channel_id), + ) + row = cursor.fetchone() + return _as_registered_channel(row) if row is not None else None + + +def record_project_discord_channel_verification( + settings: SharedSettings, + *, + mapping_id: str, + error: str | None = None, +) -> None: + """Record a live bot verification result without changing mapping ownership.""" + with get_postgres_connection(settings) as conn: + with conn.cursor() as cursor: + cursor.execute( + """ + UPDATE project_discord_channels + SET last_verified_at = NOW(), + last_verification_error = %s, + updated_at = NOW() + WHERE id = %s::uuid + """, + ((error or "").strip()[:1000] or None, mapping_id), + ) diff --git a/packages/shared/src/five08/project_payments.py b/packages/shared/src/five08/project_payments.py new file mode 100644 index 00000000..08823dea --- /dev/null +++ b/packages/shared/src/five08/project_payments.py @@ -0,0 +1,1918 @@ +"""Payment-domain normalization and durable project allocation helpers.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal, InvalidOperation +from enum import StrEnum +from hashlib import sha256 +import json +import re +from typing import Any +from uuid import NAMESPACE_URL, UUID, uuid4, uuid5 + +from psycopg.rows import dict_row +from psycopg.types.json import Jsonb + +from five08.queue import get_postgres_connection +from five08.settings import SharedSettings +from five08.automation import ( + AutomationAction, + AutomationActionDisposition, + AutomationCondition, + AutomationConditionOperator, + AutomationEvaluation, + AutomationRule, + AutomationRuleMode, + AutomationRuleOrigin, +) + + +BANK_TRANSACTION_POSTED_EVENT = "bank_transaction.posted.v1" +PROJECT_PAYMENT_ROUTE_ACTION = "project_payment.route" +PROJECT_PAYMENT_ALLOWED_FACT_PATHS = frozenset( + { + "transaction.direction", + "transaction.amount", + "transaction.currency", + "transaction.description", + "transaction.counterparty", + "transaction.reference_number", + "transaction.bank_account", + "transaction.has_reconciliation", + } +) +PROJECT_PAYMENT_ALLOWED_ACTION_TYPES = frozenset({PROJECT_PAYMENT_ROUTE_ACTION}) +_PROJECT_PAYMENT_IDENTITY_FACTS = frozenset( + { + "transaction.counterparty", + "transaction.description", + "transaction.reference_number", + } +) +_PROJECT_PAYMENT_MAX_ATTEMPTS = 8 +_PROJECT_PAYMENT_RETRY_BASE_SECONDS = 15 +_PROJECT_PAYMENT_RETRY_MAX_SECONDS = 3600 +_PROJECT_PAYMENT_LEARNED_RULE_PRIORITY = -100 +_PROJECT_PAYMENT_LEARNED_IDENTITY_FACTS = ( + ("transaction.counterparty", "counterparty"), + ("transaction.reference_number", "reference_number"), + ("transaction.description", "description"), +) +_CURRENCY_CODE_PATTERN = re.compile(r"^[A-Za-z]{3}$") + + +class PaymentDirection(StrEnum): + """Direction relative to the cooperative's bank account.""" + + INBOUND = "inbound" + OUTBOUND = "outbound" + + +class ProjectPaymentAllocationStatus(StrEnum): + """Human/accounting confirmation state for a project allocation.""" + + PROPOSED = "proposed" + CONFIRMED = "confirmed" + REJECTED = "rejected" + SUPERSEDED = "superseded" + + +class ProjectPaymentMatchMethod(StrEnum): + """The evidence source used to classify a payment against a project.""" + + ERP_RECONCILED = "erp_reconciled" + CONFIGURED_RULE = "configured_rule" + MANUAL = "manual" + LEARNED_SUGGESTION = "learned_suggestion" + + +class ProjectPaymentNotificationStatus(StrEnum): + """Delivery lifecycle for a payment receipt message.""" + + PENDING = "pending" + SENDING = "sending" + SENT = "sent" + BLOCKED = "blocked" + FAILED = "failed" + DEAD = "dead" + + +class ProjectPaymentActionApplicationStatus(StrEnum): + """Outcome of the transactional project-payment action boundary.""" + + APPLIED = "applied" + BLOCKED = "blocked" + NOT_OWNER = "not_owner" + + +def project_payment_rule_has_valid_scope(rule: AutomationRule) -> bool: + """Check that every payment-route action targets its owning project UUID.""" + route_actions = [ + action + for action in rule.actions + if action.action_type == PROJECT_PAYMENT_ROUTE_ACTION + ] + if not route_actions: + return True + try: + rule_project_id = str(UUID(str(rule.project_id))) + except (AttributeError, TypeError, ValueError): + return False + for action in route_actions: + try: + action_project_id = str(UUID(str(action.payload.get("project_id")))) + except (AttributeError, TypeError, ValueError): + return False + if action_project_id != rule_project_id: + return False + return True + + +def automatic_project_payment_rule_is_safe(rule: AutomationRule) -> bool: + """Require matching project scope plus strong evidence before auto-confirm. + + Amount-only matches are useful suggestions but can collide across unrelated + invoices. A generic ERP reconciliation flag is not project identity: the + linked payment entry must be resolved to the same local project before it + can be used as automatic evidence, and v1 deliberately does not do that. + The action payload repeats the UUID deliberately, and must agree with the + rule scope, so a generic rule cannot route a receipt to an unrelated + project. + """ + route_actions = [ + action + for action in rule.actions + if action.action_type == PROJECT_PAYMENT_ROUTE_ACTION + ] + if rule.mode is not AutomationRuleMode.AUTOMATIC or not route_actions: + return True + if rule.origin is AutomationRuleOrigin.LEARNED: + return False + if not project_payment_rule_has_valid_scope(rule): + return False + has_identifier = any( + condition.fact in _PROJECT_PAYMENT_IDENTITY_FACTS + and condition.operator + in { + AutomationConditionOperator.EQUALS, + AutomationConditionOperator.CONTAINS, + } + and isinstance(condition.value, str) + and bool(condition.value.strip()) + for condition in rule.conditions + ) + exact_amounts = { + amount + for condition in rule.conditions + if condition.fact == "transaction.amount" + and condition.operator is AutomationConditionOperator.EQUALS + if (amount := _decimal_or_none(condition.value)) is not None and amount > 0 + } + has_currency = any( + condition.fact == "transaction.currency" + and condition.operator is AutomationConditionOperator.EQUALS + and isinstance(condition.value, str) + and bool(condition.value.strip()) + for condition in rule.conditions + ) + if not (has_identifier and exact_amounts and has_currency): + return False + for action in route_actions: + configured_amount = action.payload.get("amount") + if configured_amount is None: + continue + action_amount = _decimal_or_none(configured_amount) + if ( + action_amount is None + or action_amount <= 0 + or action_amount not in exact_amounts + ): + return False + return True + + +def project_payment_rule_for_evaluation(rule: AutomationRule) -> AutomationRule: + """Downgrade unsafe automatic payment routing to a reviewable suggestion.""" + if automatic_project_payment_rule_is_safe(rule): + return rule + return rule.model_copy(update={"mode": AutomationRuleMode.SUGGEST}) + + +def learned_project_payment_suggestion_rule( + *, + project_id: str, + subject_snapshot: Mapping[str, object], +) -> AutomationRule | None: + """Compile one approved receipt into a conservative future suggestion. + + This is deliberately a small, deterministic learning loop rather than a + statistical classifier: an explicit human approval may teach a future + *review* suggestion only when the canonical ERP evidence includes an + inbound direction, a 3-letter currency, and a meaningful payer/reference + identity. The generated rule is permanently ``suggest`` mode, so it can + never create a payment allocation or Discord post without another human + approval. + """ + try: + normalized_project_id = str(UUID(str(project_id))) + except (AttributeError, TypeError, ValueError): + return None + direction = _text_or_none(subject_snapshot.get("direction")) + if direction != PaymentDirection.INBOUND.value: + return None + currency = _text_or_none(subject_snapshot.get("currency")) + if currency is None or not _CURRENCY_CODE_PATTERN.fullmatch(currency): + return None + normalized_currency = currency.upper() + + identity_fact: str | None = None + identity_value: str | None = None + for fact, snapshot_key in _PROJECT_PAYMENT_LEARNED_IDENTITY_FACTS: + candidate = _normalized_learned_identity(subject_snapshot.get(snapshot_key)) + if candidate is not None: + identity_fact = fact + identity_value = candidate + break + if identity_fact is None or identity_value is None: + return None + + learning_material = json.dumps( + { + "event_type": BANK_TRANSACTION_POSTED_EVENT, + "project_id": normalized_project_id, + "identity_fact": identity_fact, + "identity": identity_value.casefold(), + "currency": normalized_currency, + }, + sort_keys=True, + separators=(",", ":"), + ) + # Keep payer/reference values out of stable database identifiers. The + # content remains only in the rule condition, where an authorized operator + # can inspect or disable it. + learning_key = ( + "project-payment:v1:" + sha256(learning_material.encode("utf-8")).hexdigest() + ) + return AutomationRule( + id=str(uuid5(NAMESPACE_URL, learning_key)), + project_id=normalized_project_id, + event_type=BANK_TRANSACTION_POSTED_EVENT, + origin=AutomationRuleOrigin.LEARNED, + learning_key=learning_key, + priority=_PROJECT_PAYMENT_LEARNED_RULE_PRIORITY, + mode=AutomationRuleMode.SUGGEST, + conditions=[ + AutomationCondition( + fact="transaction.direction", + operator=AutomationConditionOperator.EQUALS, + value=PaymentDirection.INBOUND.value, + ), + AutomationCondition( + fact=identity_fact, + operator=AutomationConditionOperator.CONTAINS, + value=identity_value, + ), + AutomationCondition( + fact="transaction.currency", + operator=AutomationConditionOperator.EQUALS, + value=normalized_currency, + ), + ], + actions=[ + AutomationAction( + action_type=PROJECT_PAYMENT_ROUTE_ACTION, + payload={"project_id": normalized_project_id}, + ) + ], + ) + + +def _normalized_learned_identity(value: object) -> str | None: + """Return a compact meaningful identity value, or reject vague evidence.""" + if not isinstance(value, str): + return None + normalized = " ".join(value.split()) + if len(normalized) < 3 or not any(character.isalnum() for character in normalized): + return None + return normalized + + +def project_payment_evaluation_for_execution( + evaluation: AutomationEvaluation, +) -> AutomationEvaluation: + """Resolve competing automatic payment routes conservatively. + + Rule priority selects one automatic route only when the highest-priority + match is unique. Any tie, and all lower-priority automatic matches, become + human-review suggestions instead of silently splitting or racing a receipt + between projects. + """ + automatic_routes = [ + proposal + for proposal in evaluation.action_proposals + if proposal.action.action_type == PROJECT_PAYMENT_ROUTE_ACTION + and proposal.mode is AutomationRuleMode.AUTOMATIC + and proposal.disposition is AutomationActionDisposition.READY + ] + if len(automatic_routes) <= 1: + return evaluation + + highest_priority = max(proposal.rule_priority for proposal in automatic_routes) + highest = [ + proposal + for proposal in automatic_routes + if proposal.rule_priority == highest_priority + ] + winning_key: tuple[str, int, int] | None = None + if len(highest) == 1: + winner = highest[0] + winning_key = (winner.rule_id, winner.rule_version, winner.rule_priority) + + resolved_proposals = [] + for proposal in evaluation.action_proposals: + if proposal not in automatic_routes: + resolved_proposals.append(proposal) + continue + proposal_key = ( + proposal.rule_id, + proposal.rule_version, + proposal.rule_priority, + ) + if proposal_key == winning_key: + resolved_proposals.append(proposal) + continue + resolved_proposals.append( + proposal.model_copy( + update={ + "mode": AutomationRuleMode.SUGGEST, + "disposition": AutomationActionDisposition.SUGGESTED, + } + ) + ) + return evaluation.model_copy(update={"action_proposals": resolved_proposals}) + + +def project_payment_notification_idempotency_key( + *, + payment_transaction_id: str, + allocation_id: str, + project_discord_channel_id: str, +) -> str: + """Build an outbox key scoped to the durable channel registration. + + A Discord channel ID can be intentionally unregistered then registered + again. Its mapping UUID is the authorization object for a delivery, so it + must be part of deduplication rather than the reusable Discord ID. + """ + return ( + "payment-received:v2:" + f"{payment_transaction_id}:{allocation_id}:{project_discord_channel_id}" + ) + + +@dataclass(frozen=True) +class BankTransactionInput: + """Sanitized local projection of a canonical ERPNext Bank Transaction.""" + + source: str + external_id: str + source_revision: str | None + source_modified_at: datetime | None + transaction_id: str | None + posted_at: datetime | None + bank_account: str | None + direction: PaymentDirection + amount: Decimal + currency: str | None + counterparty: str | None + description: str | None + reference_number: str | None + reconciliation_entries: list[dict[str, Any]] + source_payload: dict[str, Any] + + +@dataclass(frozen=True) +class StoredBankTransaction: + """Identity of a local transaction projection.""" + + id: str + created: bool + accepted: bool = True + + +@dataclass(frozen=True) +class BankTransactionRecord: + """Fields needed by the typed payment action executor.""" + + id: str + source: str + external_id: str + source_revision: str | None + direction: PaymentDirection + amount: Decimal + currency: str | None + bank_account: str | None + counterparty: str | None + description: str | None + reference_number: str | None + posted_at: datetime | None + + +@dataclass(frozen=True) +class ProjectPaymentAllocation: + """One persisted project share of a bank receipt.""" + + id: str + created: bool + + +@dataclass(frozen=True) +class ProjectPaymentNotification: + """One durable request for the bot to announce a confirmed receipt.""" + + id: str + allocation_id: str + project_id: str + channel_id: str + payload: dict[str, Any] + status: ProjectPaymentNotificationStatus + attempts: int + lease_token: str | None + + +@dataclass(frozen=True) +class ProjectPaymentNotificationDeliveryContext: + """Canonical notification data the bot may render after DB authorization.""" + + notification_id: str + project_discord_channel_id: str + project_id: str + guild_id: str + channel_id: str + allocation_id: str + amount: Decimal + currency: str | None + posted_at: datetime | None + bank_transaction_id: str + + +@dataclass(frozen=True) +class ProjectPaymentNotificationSourceContext: + """Canonical source identity bound to an owned notification lease. + + The worker uses this compact context to re-read ERPNext immediately before + asking Discord to publish. Keeping the allocation's captured revision + alongside the current local projection makes a delayed delivery fail + closed when a Bank Transaction has been corrected or canceled. + """ + + notification_id: str + bank_transaction_id: str + source: str + external_id: str + allocation_status: ProjectPaymentAllocationStatus + allocation_source_revision: str | None + current_source_revision: str | None + + +@dataclass(frozen=True) +class ProjectPaymentActionApplication: + """Result of applying one claimed payment route inside one DB transaction.""" + + status: ProjectPaymentActionApplicationStatus + action_id: str + allocation_id: str | None = None + allocation_created: bool = False + notification_ids: tuple[str, ...] = () + reason: str | None = None + + +def _text_or_none(value: object) -> str | None: + normalized = str(value or "").strip() + return normalized or None + + +def _decimal_or_none(value: object) -> Decimal | None: + if isinstance(value, bool) or value is None: + return None + try: + candidate = Decimal(str(value).strip()) + except (InvalidOperation, ValueError): + return None + return candidate if candidate.is_finite() else None + + +def _datetime_or_none(value: object) -> datetime | None: + if isinstance(value, datetime): + return value + normalized = _text_or_none(value) + if normalized is None: + return None + try: + return datetime.fromisoformat(normalized.replace("Z", "+00:00")) + except ValueError: + return None + + +def _reconciliation_entries(value: object) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + return [dict(entry) for entry in value if isinstance(entry, dict)] + + +def erpnext_bank_transaction_to_input( + transaction: dict[str, Any], +) -> BankTransactionInput | None: + """Normalize a submitted ERPNext Bank Transaction without trusting a webhook. + + Returns ``None`` for canceled/draft records and empty zero-value records. + A document with both a deposit and a withdrawal is ambiguous and must be + repaired in ERPNext before it can feed project routing. + """ + external_id = _text_or_none(transaction.get("name")) + if external_id is None: + raise ValueError("ERPNext Bank Transaction is missing name") + + docstatus = transaction.get("docstatus") + if isinstance(docstatus, bool): + submitted = False + elif isinstance(docstatus, int | str): + try: + submitted = int(docstatus) == 1 + except ValueError: + submitted = False + else: + submitted = False + if not submitted: + return None + + deposit = _decimal_or_none(transaction.get("deposit")) or Decimal("0") + withdrawal = _decimal_or_none(transaction.get("withdrawal")) or Decimal("0") + if deposit < 0 or withdrawal < 0: + raise ValueError("ERPNext Bank Transaction contains a negative amount") + if deposit > 0 and withdrawal > 0: + raise ValueError( + "ERPNext Bank Transaction cannot be classified with both deposit and withdrawal" + ) + if deposit == 0 and withdrawal == 0: + return None + + direction = PaymentDirection.INBOUND if deposit > 0 else PaymentDirection.OUTBOUND + amount = deposit if deposit > 0 else withdrawal + entries = _reconciliation_entries(transaction.get("payment_entries")) + transaction_id = _text_or_none(transaction.get("transaction_id")) + source_revision = _text_or_none(transaction.get("modified")) + return BankTransactionInput( + source="erpnext", + external_id=external_id, + source_revision=source_revision, + source_modified_at=_datetime_or_none(source_revision), + transaction_id=transaction_id, + posted_at=_datetime_or_none( + transaction.get("date") or transaction.get("transaction_date") + ), + bank_account=_text_or_none(transaction.get("bank_account")), + direction=direction, + amount=amount, + currency=_text_or_none( + transaction.get("currency") or transaction.get("account_currency") + ), + counterparty=_text_or_none( + # ``bank_party_name`` is the payer identity supplied by the bank + # statement. ``party`` may instead be reconciliation-derived, so + # do not let a later ERP match silently replace the raw rule fact. + transaction.get("bank_party_name") + or transaction.get("party") + or transaction.get("party_name") + or transaction.get("description") + ), + description=_text_or_none(transaction.get("description")), + reference_number=_text_or_none( + transaction.get("reference_number") or transaction.get("cheque_number") + ), + reconciliation_entries=entries, + source_payload={ + "erpnext_name": external_id, + "transaction_id": transaction_id, + "docstatus": 1, + "payment_entries": entries, + }, + ) + + +def payment_automation_facts(transaction: BankTransactionInput) -> dict[str, Any]: + """Build the explicitly allowlisted fact shape exposed to payment rules.""" + return { + "transaction": { + "direction": transaction.direction.value, + "amount": format(transaction.amount, "f"), + "currency": transaction.currency, + "description": transaction.description, + "counterparty": transaction.counterparty, + "reference_number": transaction.reference_number, + "bank_account": transaction.bank_account, + "has_reconciliation": bool(transaction.reconciliation_entries), + } + } + + +def payment_automation_subject_snapshot( + transaction: BankTransactionInput, +) -> dict[str, Any]: + """Capture the immutable payment evidence used by the action executor. + + The source projection may later be refreshed in place for a newer ERP + document revision. Automation actions therefore execute from this compact, + persisted snapshot and compare its canonical revision to the live row + before allocating anything. + """ + return { + "source": transaction.source, + "external_id": transaction.external_id, + "source_revision": transaction.source_revision, + "transaction_id": transaction.transaction_id, + "direction": transaction.direction.value, + "amount": format(transaction.amount, "f"), + "currency": transaction.currency, + "bank_account": transaction.bank_account, + "counterparty": transaction.counterparty, + "description": transaction.description, + "reference_number": transaction.reference_number, + "posted_at": ( + transaction.posted_at.isoformat() + if transaction.posted_at is not None + else None + ), + "reconciliation_entries": transaction.reconciliation_entries, + } + + +def upsert_bank_transaction( + settings: SharedSettings, + transaction: BankTransactionInput, +) -> StoredBankTransaction: + """Persist a sanitized source-of-truth projection keyed by source identity.""" + row_id = str(uuid4()) + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + INSERT INTO bank_transactions ( + id, + source, + external_id, + source_revision, + source_modified_at, + transaction_id, + posted_at, + bank_account, + direction, + amount, + currency, + counterparty, + description, + reference_number, + reconciliation_entries, + source_payload + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s + ) + ON CONFLICT (source, external_id) DO UPDATE + SET source_revision = EXCLUDED.source_revision, + source_modified_at = EXCLUDED.source_modified_at, + transaction_id = EXCLUDED.transaction_id, + posted_at = EXCLUDED.posted_at, + bank_account = EXCLUDED.bank_account, + direction = EXCLUDED.direction, + amount = EXCLUDED.amount, + currency = EXCLUDED.currency, + counterparty = EXCLUDED.counterparty, + description = EXCLUDED.description, + reference_number = EXCLUDED.reference_number, + reconciliation_entries = EXCLUDED.reconciliation_entries, + source_payload = EXCLUDED.source_payload, + updated_at = NOW() + WHERE ( + EXCLUDED.source_modified_at IS NOT NULL + AND ( + bank_transactions.source_modified_at IS NULL + OR EXCLUDED.source_modified_at >= bank_transactions.source_modified_at + ) + ) OR ( + EXCLUDED.source_modified_at IS NULL + AND bank_transactions.source_modified_at IS NULL + ) + RETURNING id::text, (xmax = 0) AS created + """, + ( + row_id, + transaction.source, + transaction.external_id, + transaction.source_revision, + transaction.source_modified_at, + transaction.transaction_id, + transaction.posted_at, + transaction.bank_account, + transaction.direction.value, + transaction.amount, + transaction.currency, + transaction.counterparty, + transaction.description, + transaction.reference_number, + Jsonb(transaction.reconciliation_entries), + Jsonb(transaction.source_payload), + ), + ) + row = cursor.fetchone() + if row is not None: + stored_transaction = StoredBankTransaction( + id=str(row["id"]), + created=bool(row["created"]), + accepted=True, + ) + if ( + not stored_transaction.created + and transaction.source_revision is not None + ): + # Allocations are evidence for one immutable ERP revision. + # When canonical source state advances, retain the old row + # for audit but prevent it (and its pending outbox rows) + # from being treated as a current confirmed allocation. + # A fresh event can produce a new rule evaluation instead. + cursor.execute( + """ + UPDATE project_payment_allocations + SET status = %s, + superseded_reason = %s, + superseded_at = NOW(), + updated_at = NOW() + WHERE payment_transaction_id = %s::uuid + AND status = %s + AND match_method = %s + AND evidence ->> 'source_revision' IS DISTINCT FROM %s + """, + ( + ProjectPaymentAllocationStatus.SUPERSEDED.value, + "source_revision_changed", + stored_transaction.id, + ProjectPaymentAllocationStatus.CONFIRMED.value, + ProjectPaymentMatchMethod.CONFIGURED_RULE.value, + transaction.source_revision, + ), + ) + return stored_transaction + cursor.execute( + """ + SELECT id::text + FROM bank_transactions + WHERE source = %s AND external_id = %s + """, + (transaction.source, transaction.external_id), + ) + existing = cursor.fetchone() + if existing is None: + raise RuntimeError("Unable to persist bank transaction") + return StoredBankTransaction( + id=str(existing["id"]), + created=False, + accepted=False, + ) + + +def get_bank_transaction( + settings: SharedSettings, + *, + transaction_id: str, +) -> BankTransactionRecord | None: + """Load the constrained transaction shape needed for a routing action.""" + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT id::text, source, external_id, source_revision, direction, + amount, currency, bank_account, counterparty, description, + reference_number, posted_at + FROM bank_transactions + WHERE id = %s::uuid + """, + (transaction_id,), + ) + row = cursor.fetchone() + if row is None: + return None + return BankTransactionRecord( + id=str(row["id"]), + source=str(row["source"]), + external_id=str(row["external_id"]), + source_revision=_text_or_none(row.get("source_revision")), + direction=PaymentDirection(str(row["direction"])), + amount=Decimal(str(row["amount"])), + currency=_text_or_none(row.get("currency")), + bank_account=_text_or_none(row.get("bank_account")), + counterparty=_text_or_none(row.get("counterparty")), + description=_text_or_none(row.get("description")), + reference_number=_text_or_none(row.get("reference_number")), + posted_at=row.get("posted_at"), + ) + + +def create_project_payment_allocation( + settings: SharedSettings, + *, + payment_transaction_id: str, + project_id: str, + amount: Decimal, + currency: str | None, + status: ProjectPaymentAllocationStatus, + match_method: ProjectPaymentMatchMethod, + idempotency_key: str, + automation_action_id: str | None = None, + evidence: dict[str, Any] | None = None, +) -> ProjectPaymentAllocation: + """Create an allocation, rejecting an over-allocated confirmed receipt. + + The parent transaction and open local project rows are locked in one + transaction. That serializes concurrent allocations and project closure, + so a receipt cannot be newly auto-confirmed after its project closes. + """ + if amount <= 0: + raise ValueError("allocation amount must be greater than zero") + normalized_key = idempotency_key.strip() + if not normalized_key: + raise ValueError("idempotency_key is required") + normalized_currency = _text_or_none(currency) + + allocation_id = str(uuid4()) + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT id::text, amount, direction + FROM bank_transactions + WHERE id = %s::uuid + FOR UPDATE + """, + (payment_transaction_id,), + ) + transaction_row = cursor.fetchone() + if transaction_row is None: + raise ValueError("bank transaction was not found") + if str(transaction_row["direction"]) != PaymentDirection.INBOUND.value: + raise ValueError( + "only inbound transactions can be allocated as receipts" + ) + + cursor.execute( + """ + SELECT id::text + FROM project_payment_allocations + WHERE idempotency_key = %s + """, + (normalized_key,), + ) + existing = cursor.fetchone() + if existing is not None: + return ProjectPaymentAllocation(id=str(existing["id"]), created=False) + + cursor.execute( + """ + SELECT id::text + FROM projects + WHERE id = %s::uuid + AND LOWER(COALESCE(source_status, '')) = 'open' + FOR UPDATE + """, + (project_id,), + ) + if cursor.fetchone() is None: + raise ValueError("project_id does not identify an open local project") + + if status is ProjectPaymentAllocationStatus.CONFIRMED: + cursor.execute( + """ + SELECT amount + FROM project_payment_allocations + WHERE payment_transaction_id = %s::uuid + AND status = %s + FOR UPDATE + """, + ( + payment_transaction_id, + ProjectPaymentAllocationStatus.CONFIRMED.value, + ), + ) + allocated_amount = sum( + (Decimal(str(row["amount"])) for row in cursor.fetchall()), + Decimal("0"), + ) + transaction_amount = Decimal(str(transaction_row["amount"])) + if allocated_amount + amount > transaction_amount: + raise ValueError("confirmed allocations exceed transaction amount") + + cursor.execute( + """ + INSERT INTO project_payment_allocations ( + id, + payment_transaction_id, + project_id, + amount, + currency, + status, + match_method, + automation_action_id, + evidence, + idempotency_key + ) VALUES ( + %s, %s::uuid, %s::uuid, %s, %s, %s, %s, %s::uuid, %s, %s + ) + RETURNING id::text + """, + ( + allocation_id, + payment_transaction_id, + project_id, + amount, + normalized_currency, + status.value, + match_method.value, + automation_action_id, + Jsonb(evidence or {}), + normalized_key, + ), + ) + created = cursor.fetchone() + if created is None: + raise RuntimeError("Unable to persist project payment allocation") + return ProjectPaymentAllocation(id=str(created["id"]), created=True) + + +def project_is_open(settings: SharedSettings, *, project_id: str) -> bool: + """Return whether a local project is still active for automatic routing. + + Closed projects retain their payment history, but a new receipt must be + reviewed rather than silently allocated/announced against a stale channel. + """ + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT 1 + FROM projects + WHERE id = %s::uuid + AND LOWER(COALESCE(source_status, '')) = 'open' + """, + (project_id,), + ) + return cursor.fetchone() is not None + + +def create_project_payment_notification( + settings: SharedSettings, + *, + allocation_id: str, + project_id: str, + project_discord_channel_id: str, + channel_id: str, + idempotency_key: str, + payload: dict[str, Any], +) -> tuple[str, bool]: + """Write one notification outbox row without duplicating a channel delivery.""" + normalized_key = idempotency_key.strip() + if not normalized_key: + raise ValueError("idempotency_key is required") + notification_id = str(uuid4()) + message_payload = dict(payload) + message_payload.update( + { + "notification_id": notification_id, + "project_id": project_id, + "channel_id": channel_id, + "allocation_id": allocation_id, + } + ) + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + INSERT INTO project_payment_notification_outbox ( + id, + allocation_id, + project_discord_channel_id, + idempotency_key, + payload + ) VALUES (%s, %s::uuid, %s::uuid, %s, %s) + ON CONFLICT DO NOTHING + RETURNING id::text + """, + ( + notification_id, + allocation_id, + project_discord_channel_id, + normalized_key, + Jsonb(message_payload), + ), + ) + row = cursor.fetchone() + if row is not None: + return str(row["id"]), True + cursor.execute( + """ + SELECT id::text + FROM project_payment_notification_outbox + WHERE allocation_id = %s::uuid + AND project_discord_channel_id = %s::uuid + """, + (allocation_id, project_discord_channel_id), + ) + existing = cursor.fetchone() + if existing is None: + raise RuntimeError("Unable to load duplicate project payment notification") + return str(existing["id"]), False + + +def _as_notification_status(value: object) -> ProjectPaymentNotificationStatus: + try: + return ProjectPaymentNotificationStatus(str(value)) + except ValueError as exc: + raise ValueError(f"Unknown payment notification status: {value!r}") from exc + + +def _as_project_payment_notification(row: dict[str, Any]) -> ProjectPaymentNotification: + payload = row.get("payload") + return ProjectPaymentNotification( + id=str(row["id"]), + allocation_id=str(row["allocation_id"]), + project_id=str(row["project_id"]), + channel_id=str(row["channel_id"]), + payload=dict(payload) if isinstance(payload, dict) else {}, + status=_as_notification_status(row["status"]), + attempts=int(row["attempts"]), + lease_token=_text_or_none(row.get("lease_token")), + ) + + +def claim_project_payment_notification( + settings: SharedSettings, + *, + notification_id: str, + stale_after_seconds: int = 300, +) -> ProjectPaymentNotification | None: + """Claim an eligible notification using an owned worker-side lease.""" + lease_token = str(uuid4()) + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + UPDATE project_payment_notification_outbox outbox + SET status = %s, + attempts = attempts + 1, + locked_at = NOW(), + lease_token = %s::uuid, + updated_at = NOW() + FROM project_discord_channels channel + INNER JOIN projects project ON project.id = channel.project_id + INNER JOIN project_payment_allocations allocation + ON allocation.id = outbox.allocation_id + WHERE outbox.id = %s::uuid + AND channel.id = outbox.project_discord_channel_id + AND channel.active IS TRUE + AND LOWER(COALESCE(project.source_status, '')) = 'open' + AND allocation.status = %s + AND ( + outbox.status = %s + OR ( + outbox.status = %s + AND ( + outbox.next_attempt_at IS NULL + OR outbox.next_attempt_at <= NOW() + ) + ) + OR ( + outbox.status = %s + AND outbox.locked_at < NOW() - (%s * INTERVAL '1 second') + ) + ) + RETURNING outbox.id::text, outbox.allocation_id::text, + channel.project_id::text, channel.channel_id, outbox.payload, + outbox.status, outbox.attempts, outbox.lease_token::text + """, + ( + ProjectPaymentNotificationStatus.SENDING.value, + lease_token, + notification_id, + ProjectPaymentAllocationStatus.CONFIRMED.value, + ProjectPaymentNotificationStatus.PENDING.value, + ProjectPaymentNotificationStatus.FAILED.value, + ProjectPaymentNotificationStatus.SENDING.value, + max(1, stale_after_seconds), + ), + ) + row = cursor.fetchone() + return _as_project_payment_notification(row) if row is not None else None + + +def mark_project_payment_notification_sent( + settings: SharedSettings, + *, + notification_id: str, + discord_message_id: str, + lease_token: str, +) -> bool: + """Record the Discord message ID only for the claimant that sent it.""" + with get_postgres_connection(settings) as conn: + with conn.cursor() as cursor: + cursor.execute( + """ + UPDATE project_payment_notification_outbox + SET status = %s, + discord_message_id = %s, + last_error = NULL, + locked_at = NULL, + lease_token = NULL, + next_attempt_at = NULL, + updated_at = NOW() + WHERE id = %s::uuid + AND status = %s + AND lease_token = %s::uuid + RETURNING id + """, + ( + ProjectPaymentNotificationStatus.SENT.value, + discord_message_id, + notification_id, + ProjectPaymentNotificationStatus.SENDING.value, + lease_token, + ), + ) + return cursor.fetchone() is not None + + +def mark_project_payment_notification_failed( + settings: SharedSettings, + *, + notification_id: str, + error: str, + blocked: bool = False, + lease_token: str, +) -> bool: + """Store a failure only for the claimant that owns the active lease.""" + normalized_error = error.strip()[:2000] or "project payment notification failed" + with get_postgres_connection(settings) as conn: + with conn.cursor() as cursor: + cursor.execute( + """ + UPDATE project_payment_notification_outbox + SET status = CASE + WHEN %s THEN %s + WHEN attempts >= %s THEN %s + ELSE %s + END, + last_error = %s, + locked_at = NULL, + lease_token = NULL, + next_attempt_at = CASE + WHEN %s OR attempts >= %s THEN NULL + ELSE NOW() + ( + LEAST( + %s, + %s * POWER(2, GREATEST(attempts - 1, 0)) + ) * INTERVAL '1 second' + ) + END, + updated_at = NOW() + WHERE id = %s::uuid + AND status = %s + AND lease_token = %s::uuid + RETURNING id + """, + ( + blocked, + ProjectPaymentNotificationStatus.BLOCKED.value, + _PROJECT_PAYMENT_MAX_ATTEMPTS, + ProjectPaymentNotificationStatus.DEAD.value, + ProjectPaymentNotificationStatus.FAILED.value, + normalized_error, + blocked, + _PROJECT_PAYMENT_MAX_ATTEMPTS, + _PROJECT_PAYMENT_RETRY_MAX_SECONDS, + _PROJECT_PAYMENT_RETRY_BASE_SECONDS, + notification_id, + ProjectPaymentNotificationStatus.SENDING.value, + lease_token, + ), + ) + return cursor.fetchone() is not None + + +def list_retryable_project_payment_notification_ids( + settings: SharedSettings, + *, + limit: int = 100, + stale_after_seconds: int = 300, +) -> list[str]: + """List retryable rows, including delivery leases stranded by a crash.""" + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT id::text + FROM project_payment_notification_outbox + WHERE status = %s + OR ( + status = %s + AND (next_attempt_at IS NULL OR next_attempt_at <= NOW()) + ) + OR ( + status = %s + AND locked_at < NOW() - (%s * INTERVAL '1 second') + ) + ORDER BY + CASE WHEN status = %s THEN 0 ELSE 1 END, + COALESCE(next_attempt_at, created_at) ASC, + created_at ASC + LIMIT %s + """, + ( + ProjectPaymentNotificationStatus.PENDING.value, + ProjectPaymentNotificationStatus.FAILED.value, + ProjectPaymentNotificationStatus.SENDING.value, + max(1, stale_after_seconds), + ProjectPaymentNotificationStatus.PENDING.value, + max(1, min(limit, 1000)), + ), + ) + rows = cursor.fetchall() + return [str(row["id"]) for row in rows] + + +def block_project_payment_notification_if_ineligible( + settings: SharedSettings, + *, + notification_id: str, + stale_after_seconds: int = 300, +) -> bool: + """Terminally block ineligible queued or abandoned notification work. + + A live sender keeps its lease. A stale ``sending`` row is abandoned work; + blocking it on recovery prevents a revoked channel/project or superseded + allocation from being retried indefinitely after the sending worker + crashed. + """ + with get_postgres_connection(settings) as conn: + with conn.cursor() as cursor: + cursor.execute( + """ + UPDATE project_payment_notification_outbox outbox + SET status = %s, + last_error = 'project_channel_or_allocation_ineligible', + locked_at = NULL, + lease_token = NULL, + next_attempt_at = NULL, + updated_at = NOW() + WHERE outbox.id = %s::uuid + AND ( + outbox.status IN (%s, %s) + OR ( + outbox.status = %s + AND outbox.locked_at < NOW() - (%s * INTERVAL '1 second') + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM project_discord_channels channel + INNER JOIN projects project ON project.id = channel.project_id + INNER JOIN project_payment_allocations allocation + ON allocation.id = outbox.allocation_id + WHERE channel.id = outbox.project_discord_channel_id + AND channel.active IS TRUE + AND LOWER(COALESCE(project.source_status, '')) = 'open' + AND allocation.status = %s + ) + RETURNING outbox.id + """, + ( + ProjectPaymentNotificationStatus.BLOCKED.value, + notification_id, + ProjectPaymentNotificationStatus.PENDING.value, + ProjectPaymentNotificationStatus.FAILED.value, + ProjectPaymentNotificationStatus.SENDING.value, + max(1, stale_after_seconds), + ProjectPaymentAllocationStatus.CONFIRMED.value, + ), + ) + return cursor.fetchone() is not None + + +def get_project_payment_notification_delivery_context( + settings: SharedSettings, + *, + notification_id: str, + worker_lease_token: str, + project_discord_channel_id: str | None = None, +) -> ProjectPaymentNotificationDeliveryContext | None: + """Load bot-renderable data from the durable outbox, never HTTP input. + + The query binds a notification to its original channel mapping and checks + that mapping and the local ERP project are currently eligible. This is the + authorization source for the Discord bot's internal endpoint. The worker + lease is required so a holder of the shared API secret cannot force-send a + historical, blocked, or otherwise unclaimed notification ID. + """ + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT outbox.id::text AS notification_id, + outbox.allocation_id::text AS allocation_id, + channel.id::text AS project_discord_channel_id, + channel.project_id::text AS project_id, + channel.guild_id, + channel.channel_id, + allocation.amount, + allocation.currency, + transaction.posted_at, + transaction.id::text AS bank_transaction_id + FROM project_payment_notification_outbox outbox + INNER JOIN project_discord_channels channel + ON channel.id = outbox.project_discord_channel_id + INNER JOIN projects project ON project.id = channel.project_id + INNER JOIN project_payment_allocations allocation + ON allocation.id = outbox.allocation_id + INNER JOIN bank_transactions transaction + ON transaction.id = allocation.payment_transaction_id + WHERE outbox.id = %s::uuid + AND outbox.status = %s + AND outbox.lease_token = %s::uuid + AND (%s::uuid IS NULL OR channel.id = %s::uuid) + AND channel.active IS TRUE + AND LOWER(COALESCE(project.source_status, '')) = 'open' + AND allocation.status = %s + """, + ( + notification_id, + ProjectPaymentNotificationStatus.SENDING.value, + worker_lease_token, + project_discord_channel_id, + project_discord_channel_id, + ProjectPaymentAllocationStatus.CONFIRMED.value, + ), + ) + row = cursor.fetchone() + if row is None: + return None + return ProjectPaymentNotificationDeliveryContext( + notification_id=str(row["notification_id"]), + project_discord_channel_id=str(row["project_discord_channel_id"]), + project_id=str(row["project_id"]), + guild_id=str(row["guild_id"]), + channel_id=str(row["channel_id"]), + allocation_id=str(row["allocation_id"]), + amount=Decimal(str(row["amount"])), + currency=_text_or_none(row.get("currency")), + posted_at=row.get("posted_at"), + bank_transaction_id=str(row["bank_transaction_id"]), + ) + + +def get_project_payment_notification_source_context( + settings: SharedSettings, + *, + notification_id: str, + lease_token: str, +) -> ProjectPaymentNotificationSourceContext | None: + """Load source identity for the worker that currently owns delivery. + + This deliberately does not trust outbox JSON supplied to another runtime: + the allocation's immutable evidence is the expected revision and the + durable transaction projection is the current local revision. A missing + row means the worker no longer owns this notification or its durable + parent was removed, and must not attempt delivery. + """ + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT outbox.id::text AS notification_id, + transaction.id::text AS bank_transaction_id, + transaction.source, + transaction.external_id, + transaction.source_revision AS current_source_revision, + allocation.status AS allocation_status, + allocation.evidence ->> 'source_revision' + AS allocation_source_revision + FROM project_payment_notification_outbox outbox + INNER JOIN project_payment_allocations allocation + ON allocation.id = outbox.allocation_id + INNER JOIN bank_transactions transaction + ON transaction.id = allocation.payment_transaction_id + WHERE outbox.id = %s::uuid + AND outbox.status = %s + AND outbox.lease_token = %s::uuid + """, + ( + notification_id, + ProjectPaymentNotificationStatus.SENDING.value, + lease_token, + ), + ) + row = cursor.fetchone() + if row is None: + return None + return ProjectPaymentNotificationSourceContext( + notification_id=str(row["notification_id"]), + bank_transaction_id=str(row["bank_transaction_id"]), + source=str(row["source"]), + external_id=str(row["external_id"]), + allocation_status=ProjectPaymentAllocationStatus(str(row["allocation_status"])), + allocation_source_revision=_text_or_none(row.get("allocation_source_revision")), + current_source_revision=_text_or_none(row.get("current_source_revision")), + ) + + +def _block_claimed_project_payment_action( + cursor: Any, + *, + action_id: str, + lease_token: str, + reason: str, +) -> ProjectPaymentActionApplication: + """Dead-letter an owned action that failed a deterministic policy check.""" + cursor.execute( + """ + UPDATE automation_actions + SET status = 'dead', + last_error = %s, + locked_at = NULL, + lease_token = NULL, + next_attempt_at = NULL, + updated_at = NOW() + WHERE id = %s::uuid + AND status = 'running' + AND lease_token = %s::uuid + RETURNING id + """, + (reason[:2000], action_id, lease_token), + ) + if cursor.fetchone() is None: + return ProjectPaymentActionApplication( + status=ProjectPaymentActionApplicationStatus.NOT_OWNER, + action_id=action_id, + ) + return ProjectPaymentActionApplication( + status=ProjectPaymentActionApplicationStatus.BLOCKED, + action_id=action_id, + reason=reason, + ) + + +def _snapshot_value(snapshot: dict[str, Any], key: str) -> str | None: + return _text_or_none(snapshot.get(key)) + + +def project_payment_match_method_for_action( + *, + approved_by: str | None, + rule_origin: object, +) -> ProjectPaymentMatchMethod: + """Choose auditable allocation provenance from the immutable action path.""" + if approved_by is None: + return ProjectPaymentMatchMethod.CONFIGURED_RULE + if str(rule_origin) == AutomationRuleOrigin.LEARNED.value: + return ProjectPaymentMatchMethod.LEARNED_SUGGESTION + return ProjectPaymentMatchMethod.MANUAL + + +def apply_project_payment_automation_action( + settings: SharedSettings, + *, + action_id: str, + lease_token: str, +) -> ProjectPaymentActionApplication: + """Apply one owned project-payment action behind all mutable-state fences. + + The action claim, current-rule fence, immutable ERP revision check, open + project lock, allocation, notification outbox creation, and action success + write occur in one transaction. This is the deterministic money boundary; + Discord delivery remains a separate at-least-once side effect afterwards. + """ + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT action.id::text, + action.action_type, + action.payload, + action.mode AS action_mode, + action.disposition, + action.approved_by, + evaluation.rule_version, + evaluation.rule_project_id::text AS rule_project_id, + rule.version AS current_rule_version, + rule.mode AS current_rule_mode, + rule.origin AS current_rule_origin, + rule.enabled AS current_rule_enabled, + event.subject_id, + event.subject_revision AS event_subject_revision, + event.subject_snapshot + FROM automation_actions action + INNER JOIN automation_rule_evaluations evaluation + ON evaluation.id = action.rule_evaluation_id + INNER JOIN automation_rules rule ON rule.id = evaluation.rule_id + INNER JOIN automation_events event ON event.id = action.event_id + WHERE action.id = %s::uuid + AND action.status = 'running' + AND action.lease_token = %s::uuid + FOR UPDATE OF action, rule + """, + (action_id, lease_token), + ) + action = cursor.fetchone() + if action is None: + return ProjectPaymentActionApplication( + status=ProjectPaymentActionApplicationStatus.NOT_OWNER, + action_id=action_id, + ) + + if str(action["action_type"]) != PROJECT_PAYMENT_ROUTE_ACTION: + return _block_claimed_project_payment_action( + cursor, + action_id=action_id, + lease_token=lease_token, + reason="unregistered_project_payment_action_type", + ) + + approved_by = _text_or_none(action.get("approved_by")) + action_mode = str(action["action_mode"]) + disposition = str(action["disposition"]) + if approved_by is None: + if not ( + action_mode == AutomationRuleMode.AUTOMATIC.value + and disposition == AutomationActionDisposition.READY.value + and bool(action["current_rule_enabled"]) + and str(action["current_rule_mode"]) + == AutomationRuleMode.AUTOMATIC.value + and int(action["rule_version"]) + == int(action["current_rule_version"]) + ): + return _block_claimed_project_payment_action( + cursor, + action_id=action_id, + lease_token=lease_token, + reason="rule_version_superseded_or_not_automatic", + ) + elif disposition != AutomationActionDisposition.SUGGESTED.value: + return _block_claimed_project_payment_action( + cursor, + action_id=action_id, + lease_token=lease_token, + reason="human_approval_requires_a_suggested_action", + ) + + payload = action.get("payload") + payload = dict(payload) if isinstance(payload, dict) else {} + project_id = _text_or_none(payload.get("project_id")) + rule_project_id = _text_or_none(action.get("rule_project_id")) + try: + normalized_project_id = str(UUID(str(project_id))) + normalized_rule_project_id = str(UUID(str(rule_project_id))) + except (TypeError, ValueError, AttributeError): + return _block_claimed_project_payment_action( + cursor, + action_id=action_id, + lease_token=lease_token, + reason="project_payment_action_requires_a_project_scoped_rule", + ) + if normalized_project_id != normalized_rule_project_id: + return _block_claimed_project_payment_action( + cursor, + action_id=action_id, + lease_token=lease_token, + reason="project_payment_action_scope_mismatch", + ) + + snapshot_raw = action.get("subject_snapshot") + snapshot = dict(snapshot_raw) if isinstance(snapshot_raw, dict) else {} + snapshot_source = _snapshot_value(snapshot, "source") + snapshot_external_id = _snapshot_value(snapshot, "external_id") + snapshot_revision = _snapshot_value(snapshot, "source_revision") + snapshot_amount = _decimal_or_none(snapshot.get("amount")) + snapshot_direction = _snapshot_value(snapshot, "direction") + snapshot_currency = _snapshot_value(snapshot, "currency") + if ( + snapshot_source is None + or snapshot_external_id is None + or snapshot_amount is None + or snapshot_amount <= 0 + or snapshot_direction is None + ): + return _block_claimed_project_payment_action( + cursor, + action_id=action_id, + lease_token=lease_token, + reason="payment_event_snapshot_is_incomplete", + ) + if _text_or_none(action.get("event_subject_revision")) != snapshot_revision: + return _block_claimed_project_payment_action( + cursor, + action_id=action_id, + lease_token=lease_token, + reason="payment_event_snapshot_revision_mismatch", + ) + if approved_by is None and snapshot_revision is None: + return _block_claimed_project_payment_action( + cursor, + action_id=action_id, + lease_token=lease_token, + reason="automatic_payment_requires_a_canonical_erp_revision", + ) + if approved_by is None and snapshot_currency is None: + return _block_claimed_project_payment_action( + cursor, + action_id=action_id, + lease_token=lease_token, + reason="automatic_payment_requires_source_currency", + ) + try: + snapshot_payment_direction = PaymentDirection(snapshot_direction) + except ValueError: + return _block_claimed_project_payment_action( + cursor, + action_id=action_id, + lease_token=lease_token, + reason="payment_event_snapshot_has_unknown_direction", + ) + if snapshot_payment_direction is not PaymentDirection.INBOUND: + return _block_claimed_project_payment_action( + cursor, + action_id=action_id, + lease_token=lease_token, + reason="outbound_transactions_cannot_route_project_payments", + ) + configured_amount = payload.get("amount") + allocation_amount = snapshot_amount + if configured_amount is not None: + expected_amount = _decimal_or_none(configured_amount) + if expected_amount is None or expected_amount <= 0: + return _block_claimed_project_payment_action( + cursor, + action_id=action_id, + lease_token=lease_token, + reason="project_payment_action_amount_is_invalid", + ) + if approved_by is None and expected_amount != snapshot_amount: + return _block_claimed_project_payment_action( + cursor, + action_id=action_id, + lease_token=lease_token, + reason="automatic_payment_amount_must_equal_snapshot", + ) + allocation_amount = expected_amount + + subject_id = _text_or_none(action.get("subject_id")) + if subject_id is None: + return _block_claimed_project_payment_action( + cursor, + action_id=action_id, + lease_token=lease_token, + reason="payment_event_has_no_transaction_subject", + ) + cursor.execute( + """ + SELECT id::text, source, external_id, source_revision, direction, + amount, currency + FROM bank_transactions + WHERE id = %s::uuid + FOR UPDATE + """, + (subject_id,), + ) + transaction = cursor.fetchone() + if transaction is None: + return _block_claimed_project_payment_action( + cursor, + action_id=action_id, + lease_token=lease_token, + reason="payment_transaction_not_found", + ) + current_revision = _text_or_none(transaction.get("source_revision")) + if ( + str(transaction["source"]) != snapshot_source + or str(transaction["external_id"]) != snapshot_external_id + or current_revision != snapshot_revision + or str(transaction["direction"]) != snapshot_payment_direction.value + or Decimal(str(transaction["amount"])) != snapshot_amount + or _text_or_none(transaction.get("currency")) != snapshot_currency + ): + return _block_claimed_project_payment_action( + cursor, + action_id=action_id, + lease_token=lease_token, + reason="payment_source_revision_superseded", + ) + + cursor.execute( + """ + SELECT id::text + FROM projects + WHERE id = %s::uuid + AND LOWER(COALESCE(source_status, '')) = 'open' + FOR UPDATE + """, + (normalized_project_id,), + ) + if cursor.fetchone() is None: + return _block_claimed_project_payment_action( + cursor, + action_id=action_id, + lease_token=lease_token, + reason="project_is_not_open", + ) + + allocation_key = f"project-payment-route:v2:{action_id}" + cursor.execute( + """ + SELECT id::text + FROM project_payment_allocations + WHERE idempotency_key = %s + """, + (allocation_key,), + ) + existing_allocation = cursor.fetchone() + allocation_created = existing_allocation is None + if existing_allocation is not None: + allocation_id = str(existing_allocation["id"]) + else: + cursor.execute( + """ + SELECT id::text, amount + FROM project_payment_allocations + WHERE payment_transaction_id = %s::uuid + AND status = %s + FOR UPDATE + """, + (subject_id, ProjectPaymentAllocationStatus.CONFIRMED.value), + ) + allocation_rows = cursor.fetchall() + allocated_amount = sum( + (Decimal(str(row["amount"])) for row in allocation_rows), + Decimal("0"), + ) + if approved_by is None and allocation_rows: + return _block_claimed_project_payment_action( + cursor, + action_id=action_id, + lease_token=lease_token, + reason="payment_revision_requires_manual_reconciliation", + ) + if allocated_amount + allocation_amount > snapshot_amount: + return _block_claimed_project_payment_action( + cursor, + action_id=action_id, + lease_token=lease_token, + reason="confirmed_allocations_exceed_payment_snapshot_amount", + ) + allocation_id = str(uuid4()) + cursor.execute( + """ + INSERT INTO project_payment_allocations ( + id, + payment_transaction_id, + project_id, + amount, + currency, + status, + match_method, + automation_action_id, + evidence, + idempotency_key + ) VALUES ( + %s, %s::uuid, %s::uuid, %s, %s, %s, %s, %s::uuid, %s, %s + ) + RETURNING id::text + """, + ( + allocation_id, + subject_id, + normalized_project_id, + allocation_amount, + snapshot_currency, + ProjectPaymentAllocationStatus.CONFIRMED.value, + project_payment_match_method_for_action( + approved_by=approved_by, + rule_origin=action.get("current_rule_origin"), + ).value, + action_id, + Jsonb( + { + "automation_action_id": action_id, + "approved_by": approved_by, + "source_revision": snapshot_revision, + "event_snapshot": snapshot, + } + ), + allocation_key, + ), + ) + created_allocation = cursor.fetchone() + if created_allocation is None: + raise RuntimeError("Unable to create project payment allocation") + allocation_id = str(created_allocation["id"]) + + cursor.execute( + """ + SELECT id::text, channel_id + FROM project_discord_channels + WHERE project_id = %s::uuid + AND active IS TRUE + FOR SHARE + """, + (normalized_project_id,), + ) + channels = cursor.fetchall() + notification_ids: list[str] = [] + for channel in channels: + notification_id = str(uuid4()) + project_discord_channel_id = str(channel["id"]) + notification_key = project_payment_notification_idempotency_key( + payment_transaction_id=subject_id, + allocation_id=allocation_id, + project_discord_channel_id=project_discord_channel_id, + ) + cursor.execute( + """ + INSERT INTO project_payment_notification_outbox ( + id, + allocation_id, + project_discord_channel_id, + idempotency_key, + payload + ) VALUES (%s, %s::uuid, %s::uuid, %s, %s) + ON CONFLICT DO NOTHING + RETURNING id::text + """, + ( + notification_id, + allocation_id, + project_discord_channel_id, + notification_key, + Jsonb( + { + "automation_action_id": action_id, + "source_revision": snapshot_revision, + } + ), + ), + ) + created_notification = cursor.fetchone() + if created_notification is None: + cursor.execute( + """ + SELECT id::text + FROM project_payment_notification_outbox + WHERE allocation_id = %s::uuid + AND project_discord_channel_id = %s::uuid + """, + (allocation_id, project_discord_channel_id), + ) + created_notification = cursor.fetchone() + if created_notification is None: + raise RuntimeError("Unable to persist project payment notification") + notification_ids.append(str(created_notification["id"])) + + result = { + "allocation_id": allocation_id, + "allocation_created": allocation_created, + "notification_ids": notification_ids, + "source_revision": snapshot_revision, + } + cursor.execute( + """ + UPDATE automation_actions + SET status = 'succeeded', + result = %s, + last_error = NULL, + locked_at = NULL, + lease_token = NULL, + next_attempt_at = NULL, + updated_at = NOW() + WHERE id = %s::uuid + AND status = 'running' + AND lease_token = %s::uuid + RETURNING id + """, + (Jsonb(result), action_id, lease_token), + ) + if cursor.fetchone() is None: + raise RuntimeError( + "Lost project payment action lease during application" + ) + return ProjectPaymentActionApplication( + status=ProjectPaymentActionApplicationStatus.APPLIED, + action_id=action_id, + allocation_id=allocation_id, + allocation_created=allocation_created, + notification_ids=tuple(notification_ids), + ) diff --git a/packages/shared/src/five08/projects.py b/packages/shared/src/five08/projects.py index 72ef2df0..d870c616 100644 --- a/packages/shared/src/five08/projects.py +++ b/packages/shared/src/five08/projects.py @@ -160,48 +160,71 @@ def erpnext_project_to_input(project: dict[str, Any]) -> ProjectInput | None: def upsert_project(settings: SharedSettings, payload: ProjectInput) -> str: - """Upsert one source project and its current roster, returning local id.""" + """Upsert one source project and its current roster, returning local id. + + ERPNext's ``modified`` timestamp is a source revision fence. A late full + sync must never overwrite a newer action-time project refresh (especially + a newly closed project) and reopen the local routing target. + """ roster_members = payload.roster_members or [] seen_source_user_ids: set[str] = set() with get_postgres_connection(settings) as conn: with conn.cursor(row_factory=dict_row) as cursor: - project_id = _resolve_or_create_project_id(cursor, payload) - cursor.execute( - """ - UPDATE projects - SET - display_name = %s, - customer = %s, - source_status = %s, - project_type = %s, - priority = %s, - percent_complete = %s, - expected_start_date = %s, - expected_end_date = %s, - actual_start_date = %s, - actual_end_date = %s, - source_modified_at = %s, - source_payload = %s, - last_synced_at = NOW() - WHERE id = %s - """, - ( - payload.display_name, - payload.customer, - payload.source_status, - payload.project_type, - payload.priority, - payload.percent_complete, - payload.expected_start_date, - payload.expected_end_date, - payload.actual_start_date, - payload.actual_end_date, - payload.source_modified_at, - Jsonb(payload.source_payload or {}), - project_id, - ), - ) + project_id, created = _resolve_or_create_project_id(cursor, payload) + accepted = created + if not created: + cursor.execute( + """ + UPDATE projects + SET + display_name = %s, + customer = %s, + source_status = %s, + project_type = %s, + priority = %s, + percent_complete = %s, + expected_start_date = %s, + expected_end_date = %s, + actual_start_date = %s, + actual_end_date = %s, + source_modified_at = %s, + source_payload = %s, + last_synced_at = NOW() + WHERE id = %s + AND %s IS NOT NULL + AND ( + projects.source_modified_at IS NULL + OR %s > projects.source_modified_at + ) + RETURNING id + """, + ( + payload.display_name, + payload.customer, + payload.source_status, + payload.project_type, + payload.priority, + payload.percent_complete, + payload.expected_start_date, + payload.expected_end_date, + payload.actual_start_date, + payload.actual_end_date, + payload.source_modified_at, + Jsonb(payload.source_payload or {}), + project_id, + payload.source_modified_at, + payload.source_modified_at, + ), + ) + accepted = cursor.fetchone() is not None + + if not accepted: + # Do not let an old (or unversioned) payload touch roster data + # either: removing a current member roster is another form of + # stale-cache corruption. The identity mapping already exists. + return project_id + cursor.execute( """ INSERT INTO project_external_ids ( @@ -279,7 +302,7 @@ def upsert_project(settings: SharedSettings, payload: ProjectInput) -> str: def _resolve_or_create_project_id( cursor: Any, payload: ProjectInput, -) -> str: +) -> tuple[str, bool]: lock_key = f"project:{payload.source}:{payload.external_id}" cursor.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", (lock_key,)) cursor.execute( @@ -295,7 +318,7 @@ def _resolve_or_create_project_id( ) row = cursor.fetchone() if row is not None: - return str(row["project_id"]) + return str(row["project_id"]), False project_id = str(uuid4()) cursor.execute( @@ -332,7 +355,7 @@ def _resolve_or_create_project_id( Jsonb(payload.source_payload or {}), ), ) - return project_id + return project_id, True def list_dashboard_projects( @@ -667,8 +690,6 @@ def mark_missing_erpnext_open_projects_not_open( if (normalized_id := text_or_none(external_id)) is not None } ) - if not normalized_ids: - return 0 with get_postgres_connection(settings) as conn: with conn.cursor() as cursor: cursor.execute( @@ -688,6 +709,30 @@ def mark_missing_erpnext_open_projects_not_open( return int(cursor.rowcount or 0) +def erpnext_project_external_id( + settings: SharedSettings, + *, + project_id: str, +) -> str | None: + """Return the active ERPNext identity for one local project cache row.""" + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + SELECT external_id + FROM project_external_ids + WHERE project_id = %s::uuid + AND source = %s + AND active IS TRUE + ORDER BY last_seen_at DESC + LIMIT 1 + """, + (project_id, PROJECT_SOURCE_ERPNEXT), + ) + row = cursor.fetchone() + return text_or_none(row.get("external_id")) if row is not None else None + + def project_viewer_emails_for_discord( settings: SharedSettings, discord_user_id: str, diff --git a/packages/shared/src/five08/queue.py b/packages/shared/src/five08/queue.py index 6b412d8a..59ab5f35 100644 --- a/packages/shared/src/five08/queue.py +++ b/packages/shared/src/five08/queue.py @@ -390,6 +390,47 @@ def mark_job_dead( ) +def revive_dead_job( + settings: SharedSettings, + job_id: str, + *, + expected_job_type: str, +) -> bool: + """Atomically return one matching dead job to the queue. + + This is deliberately narrower than a generic retry: callers must name the + job type and the row must still be ``dead``. A concurrent caller can win + the transition, while later duplicate deliveries leave queued, running, + and terminal jobs untouched. + """ + with get_postgres_connection(settings) as conn: + with conn.cursor(row_factory=dict_row) as cursor: + cursor.execute( + """ + UPDATE jobs + SET + status = %s, + attempts = 0, + run_after = NULL, + locked_at = NULL, + locked_by = NULL, + last_error = NULL, + updated_at = NOW() + WHERE id = %s + AND type = %s + AND status = %s + RETURNING id + """, + ( + JobStatus.QUEUED, + job_id, + expected_job_type, + JobStatus.DEAD, + ), + ) + return cursor.fetchone() is not None + + def enqueue_job( queue: QueueClient, fn: Callable[..., Any], diff --git a/packages/shared/src/five08/runtime_config.py b/packages/shared/src/five08/runtime_config.py index 2611f002..7308f45e 100644 --- a/packages/shared/src/five08/runtime_config.py +++ b/packages/shared/src/five08/runtime_config.py @@ -72,6 +72,45 @@ class RuntimeConfigDBSnapshot: is_secret=True, env_names=("ERPNEXT_API_KEY",), ), + RuntimeConfigDefinition( + key="ERPNEXT_BANK_TRANSACTION_WEBHOOK_SIGNING_SECRET", + attr="erpnext_bank_transaction_webhook_signing_secret", + label="ERPNext Bank Transaction webhook secret", + category="Projects", + description="Frappe HMAC secret accepted only for submitted Bank Transaction webhook hints.", + is_secret=True, + env_names=("ERPNEXT_BANK_TRANSACTION_WEBHOOK_SIGNING_SECRET",), + ), + RuntimeConfigDefinition( + key="PROJECT_PAYMENT_AUTOMATION_ENABLED", + attr="project_payment_automation_enabled", + label="Project payment automation enabled", + category="Projects", + description="Allow safe automatic project-payment routing actions to execute.", + value_type="bool", + env_names=("PROJECT_PAYMENT_AUTOMATION_ENABLED",), + restart_required=True, + ), + RuntimeConfigDefinition( + key="PROJECT_PAYMENT_NOTIFICATIONS_ENABLED", + attr="project_payment_notifications_enabled", + label="Project payment Discord notifications enabled", + category="Projects", + description="Allow the worker to deliver confirmed project-payment receipts through the Discord bot.", + value_type="bool", + env_names=("PROJECT_PAYMENT_NOTIFICATIONS_ENABLED",), + ), + RuntimeConfigDefinition( + key="PROJECT_PAYMENT_RECOVERY_INTERVAL_SECONDS", + attr="project_payment_recovery_interval_seconds", + label="Project payment recovery interval seconds", + category="Projects", + description="Seconds between recovery sweeps for stalled payment actions, learned suggestions, and notification deliveries.", + value_type="int", + env_names=("PROJECT_PAYMENT_RECOVERY_INTERVAL_SECONDS",), + restart_required=True, + min_value=60, + ), RuntimeConfigDefinition( key="OUTLINE_BASE_URL", attr="outline_base_url", diff --git a/packages/shared/src/five08/settings.py b/packages/shared/src/five08/settings.py index 943bdc22..383c14c8 100644 --- a/packages/shared/src/five08/settings.py +++ b/packages/shared/src/five08/settings.py @@ -70,6 +70,10 @@ class SharedSettings(BaseSettings): erpnext_base_url: str | None = None erpnext_api_key: str | None = None erpnext_api_timeout_seconds: float = 20.0 + erpnext_bank_transaction_webhook_signing_secret: str | None = None + project_payment_automation_enabled: bool = False + project_payment_notifications_enabled: bool = False + project_payment_recovery_interval_seconds: int = Field(default=300, ge=60) migadu_api_user: str | None = Field( default=None, validation_alias=AliasChoices( diff --git a/tests/unit/test_automation.py b/tests/unit/test_automation.py new file mode 100644 index 00000000..5fc731e5 --- /dev/null +++ b/tests/unit/test_automation.py @@ -0,0 +1,224 @@ +"""Unit tests for the typed automation rule evaluator.""" + +from datetime import datetime, timezone + +import pytest +from pydantic import ValidationError + +from five08.automation import ( + AutomationAction, + AutomationActionDisposition, + AutomationCondition, + AutomationConditionOperator, + AutomationEventInput, + AutomationRule, + AutomationRuleMode, + AutomationRuleOrigin, + evaluate_automation_rules, + resolve_fact_path, +) + + +def _payment_event() -> AutomationEventInput: + return AutomationEventInput( + event_id="event-1", + event_type="bank_transaction.posted.v1", + source="erpnext", + subject_id="BT-0001", + occurred_at=datetime(2026, 7, 16, tzinfo=timezone.utc), + facts={ + "transaction": { + "direction": "inbound", + "amount": "1250.00", + "description": "Acme Design Sprint payment", + "counterparty": "Acme Ltd", + } + }, + ) + + +@pytest.mark.parametrize( + "payload", + [ + { + "id": "rule-1", + "event_type": "bank_transaction.posted.v1", + "origin": AutomationRuleOrigin.LEARNED, + "actions": [{"action_type": "project_payment.route"}], + }, + { + "id": "rule-1", + "event_type": "bank_transaction.posted.v1", + "origin": AutomationRuleOrigin.CONFIGURED, + "learning_key": "project-payment:v1:abc123", + "actions": [{"action_type": "project_payment.route"}], + }, + ], +) +def test_rule_learning_provenance_is_not_optional_or_spoofable( + payload: dict[str, object], +) -> None: + with pytest.raises(ValidationError): + AutomationRule.model_validate(payload) + + +def test_evaluator_matches_typed_conditions_in_deterministic_priority_order() -> None: + event = _payment_event() + lower_priority = AutomationRule( + id="rule-z", + event_type=event.event_type, + priority=1, + mode=AutomationRuleMode.SUGGEST, + conditions=[ + AutomationCondition( + fact="transaction.counterparty", + operator=AutomationConditionOperator.EQUALS, + value="Acme Ltd", + ) + ], + actions=[AutomationAction(action_type="project_payment.allocate")], + ) + higher_priority = AutomationRule( + id="rule-a", + event_type=event.event_type, + priority=10, + mode=AutomationRuleMode.AUTOMATIC, + conditions=[ + AutomationCondition( + fact="transaction.direction", + operator=AutomationConditionOperator.EQUALS, + value="inbound", + ), + AutomationCondition( + fact="transaction.amount", + operator=AutomationConditionOperator.GREATER_THAN_OR_EQUAL, + value="1000", + ), + AutomationCondition( + fact="transaction.description", + operator=AutomationConditionOperator.CONTAINS, + value="design sprint", + ), + ], + actions=[AutomationAction(action_type="discord.project_payment.notify")], + ) + + result = evaluate_automation_rules( + event, + [lower_priority, higher_priority], + allowed_fact_paths={ + "transaction.direction", + "transaction.amount", + "transaction.description", + "transaction.counterparty", + }, + allowed_action_types={ + "project_payment.allocate", + "discord.project_payment.notify", + }, + ) + + assert [trace.rule_id for trace in result.rules] == ["rule-a", "rule-z"] + assert [proposal.action.action_type for proposal in result.action_proposals] == [ + "discord.project_payment.notify", + "project_payment.allocate", + ] + assert result.action_proposals[0].disposition is AutomationActionDisposition.READY + assert ( + result.action_proposals[1].disposition is AutomationActionDisposition.SUGGESTED + ) + assert result.rules[0].rule_snapshot["conditions"][1]["value"] == "1000" + assert result.rules[0].rule_snapshot["actions"] == [ + {"action_type": "discord.project_payment.notify", "payload": {}} + ] + + +def test_evaluator_fails_closed_for_unregistered_fact_paths_and_actions() -> None: + event = _payment_event() + rule = AutomationRule( + id="rule-1", + event_type=event.event_type, + mode=AutomationRuleMode.AUTOMATIC, + conditions=[ + AutomationCondition( + fact="transaction.counterparty", + operator=AutomationConditionOperator.EXISTS, + ) + ], + actions=[AutomationAction(action_type="discord.project_payment.notify")], + ) + + result = evaluate_automation_rules( + event, + [rule], + allowed_fact_paths={"transaction.amount"}, + allowed_action_types={"project_payment.allocate"}, + ) + + assert result.rules[0].matched is False + assert result.rules[0].conditions[0].reason == "fact_path_not_allowed" + assert result.action_proposals == [] + + +def test_evaluator_does_not_treat_missing_values_as_not_equal_matches() -> None: + event = _payment_event() + rule = AutomationRule( + id="rule-1", + event_type=event.event_type, + conditions=[ + AutomationCondition( + fact="transaction.reference_number", + operator=AutomationConditionOperator.NOT_EQUALS, + value="ignore-me", + ) + ], + actions=[AutomationAction(action_type="project_payment.allocate")], + ) + + result = evaluate_automation_rules(event, [rule]) + + assert result.rules[0].matched is False + assert result.rules[0].conditions[0].reason == "fact_missing" + + +def test_evaluator_compares_equivalent_finite_json_numbers_for_equality() -> None: + event = _payment_event() + rule = AutomationRule( + id="rule-1", + event_type=event.event_type, + conditions=[ + AutomationCondition( + fact="transaction.amount", + operator=AutomationConditionOperator.EQUALS, + value=1250, + ) + ], + actions=[AutomationAction(action_type="project_payment.allocate")], + ) + + result = evaluate_automation_rules(event, [rule]) + + assert result.rules[0].matched is True + + +def test_rule_contract_rejects_expressions_and_invalid_numeric_conditions() -> None: + with pytest.raises(ValidationError, match="dotted lowercase identifier"): + AutomationCondition( + fact="transaction.__class__", + operator=AutomationConditionOperator.EXISTS, + ) + + with pytest.raises(ValidationError, match="finite numeric"): + AutomationCondition( + fact="transaction.amount", + operator=AutomationConditionOperator.GREATER_THAN_OR_EQUAL, + value="not-a-number", + ) + + +def test_resolve_fact_path_only_traverses_mappings() -> None: + assert ( + resolve_fact_path({"transaction": {"amount": "10"}}, "transaction.amount") + == "10" + ) + assert resolve_fact_path({"transaction": ["10"]}, "transaction.0") is not None diff --git a/tests/unit/test_automation_store.py b/tests/unit/test_automation_store.py new file mode 100644 index 00000000..1af63dd4 --- /dev/null +++ b/tests/unit/test_automation_store.py @@ -0,0 +1,456 @@ +"""Unit tests for the durable automation event/action store SQL contracts.""" + +from __future__ import annotations + +from contextlib import contextmanager +from datetime import datetime, timezone + +from five08.automation import ( + AutomationAction, + AutomationCondition, + AutomationConditionOperator, + AutomationEventInput, + AutomationRule, + AutomationRuleMode, + AutomationRuleOrigin, + evaluate_automation_rules, +) +import five08.automation_store as automation_store + + +class _CursorStub: + def __init__(self, rows: list[dict | None]) -> None: + self.rows = list(rows) + self.executed: list[tuple[str, tuple]] = [] + + def __enter__(self) -> "_CursorStub": + return self + + def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001 + return None + + def execute(self, query: str, params: tuple) -> None: + self.executed.append((query, params)) + + def fetchone(self) -> dict | None: + return self.rows.pop(0) if self.rows else None + + def fetchall(self) -> list[dict]: + rows = [row for row in self.rows if row is not None] + self.rows.clear() + return rows + + +class _ConnectionStub: + def __init__(self, cursor: _CursorStub) -> None: + self.cursor_stub = cursor + + def cursor(self, row_factory=None) -> _CursorStub: # noqa: ARG002 + return self.cursor_stub + + def __enter__(self) -> "_ConnectionStub": + return self + + def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001 + return None + + +def _install_connection_stub(monkeypatch, cursor: _CursorStub) -> None: + @contextmanager + def _connection(): + yield _ConnectionStub(cursor) + + monkeypatch.setattr( + automation_store, "get_postgres_connection", lambda _: _connection() + ) + + +def _event() -> AutomationEventInput: + return AutomationEventInput( + event_id="event-key", + event_type="bank_transaction.posted.v1", + source="erpnext", + subject_id="transaction-uuid", + occurred_at=datetime(2026, 7, 16, tzinfo=timezone.utc), + facts={"transaction": {"direction": "inbound"}}, + ) + + +def _rule_row(*, version: int = 1, enabled: bool = True) -> dict: + return { + "id": "00000000-0000-0000-0000-000000000001", + "project_id": "00000000-0000-0000-0000-000000000002", + "event_type": "bank_transaction.posted.v1", + "origin": "configured", + "learning_key": None, + "priority": 10, + "mode": "suggest", + "enabled": enabled, + "version": version, + "conditions": [ + { + "fact": "transaction.direction", + "operator": "equals", + "value": "inbound", + } + ], + "actions": [ + { + "action_type": "project_payment.route", + "payload": {"project_id": "00000000-0000-0000-0000-000000000002"}, + } + ], + "created_by": "discord:steering-1", + } + + +def _rule(*, version: int = 1) -> AutomationRule: + row = _rule_row(version=version) + row.pop("created_by") + return AutomationRule.model_validate(row) + + +def _action_row( + *, + status: str, + review_decision: str | None, + reviewed_by: str | None, +) -> dict: + return { + "id": "00000000-0000-0000-0000-000000000003", + "event_id": "00000000-0000-0000-0000-000000000004", + "action_type": "project_payment.route", + "payload": {"project_id": "00000000-0000-0000-0000-000000000002"}, + "mode": "suggest", + "disposition": "suggested", + "status": status, + "attempts": 0, + "idempotency_key": "action-key", + "lease_token": None, + "approved_by": (reviewed_by if review_decision == "approved" else None), + "rule_project_id": "00000000-0000-0000-0000-000000000002", + "review_decision": review_decision, + "reviewed_by": reviewed_by, + "reviewed_at": datetime(2026, 7, 16, tzinfo=timezone.utc), + } + + +def test_upsert_automation_event_returns_created_inbox_row(monkeypatch) -> None: + cursor = _CursorStub([{"id": "event-uuid"}]) + _install_connection_stub(monkeypatch, cursor) + + result = automation_store.upsert_automation_event( + automation_store.SharedSettings(), event=_event(), event_key="event-key" + ) + + assert result == automation_store.StoredAutomationEvent( + id="event-uuid", created=True + ) + assert "ON CONFLICT (event_key) DO NOTHING" in cursor.executed[0][0] + assert cursor.executed[0][1][1] == "event-key" + + +def test_upsert_automation_event_loads_existing_duplicate(monkeypatch) -> None: + cursor = _CursorStub([None, {"id": "existing-event"}]) + _install_connection_stub(monkeypatch, cursor) + + result = automation_store.upsert_automation_event( + automation_store.SharedSettings(), event=_event(), event_key="event-key" + ) + + assert result == automation_store.StoredAutomationEvent( + id="existing-event", created=False + ) + assert "SELECT id FROM automation_events" in cursor.executed[1][0] + + +def test_persist_automation_evaluation_writes_semantic_action_outbox( + monkeypatch, +) -> None: + cursor = _CursorStub([{"id": "evaluation-uuid"}, {"id": "action-uuid"}]) + _install_connection_stub(monkeypatch, cursor) + event = _event() + rule = AutomationRule( + id="00000000-0000-0000-0000-000000000001", + event_type=event.event_type, + conditions=[ + AutomationCondition( + fact="transaction.direction", + operator=AutomationConditionOperator.EQUALS, + value="inbound", + ) + ], + actions=[ + AutomationAction( + action_type="project_payment.route", + payload={"project_id": "00000000-0000-0000-0000-000000000002"}, + ) + ], + ) + evaluation = evaluate_automation_rules(event, [rule]) + + action_ids = automation_store.persist_automation_evaluation( + automation_store.SharedSettings(), + stored_event_id="00000000-0000-0000-0000-000000000003", + evaluation=evaluation, + ) + + assert action_ids == ["action-uuid"] + assert "automation_rule_evaluations" in cursor.executed[0][0] + assert "rule_snapshot" in cursor.executed[0][0] + assert cursor.executed[0][1][-1].obj["actions"] == [ + { + "action_type": "project_payment.route", + "payload": {"project_id": "00000000-0000-0000-0000-000000000002"}, + } + ] + assert "automation_actions" in cursor.executed[1][0] + assert cursor.executed[1][1][7] == "awaiting_review" + + +def test_payment_recovery_lists_stale_running_actions(monkeypatch) -> None: + cursor = _CursorStub([{"id": "stale-action"}]) + _install_connection_stub(monkeypatch, cursor) + + result = automation_store.list_pending_automation_action_ids( + automation_store.SharedSettings(), + stale_after_seconds=90, + ) + + assert result == ["stale-action"] + assert "status = %s" in cursor.executed[0][0] + assert cursor.executed[0][1][3:5] == ("running", 90) + + +def test_persist_event_and_evaluation_is_one_atomic_inbox_outbox_transaction( + monkeypatch, +) -> None: + cursor = _CursorStub( + [ + {"id": "event-uuid"}, + {"id": "evaluation-uuid"}, + {"id": "action-uuid"}, + ] + ) + _install_connection_stub(monkeypatch, cursor) + event = _event() + evaluation = evaluate_automation_rules(event, [_rule()]) + + stored_event, action_ids = automation_store.persist_automation_event_and_evaluation( + automation_store.SharedSettings(), + event=event, + event_key="event-key", + evaluation=evaluation, + ) + + assert stored_event == automation_store.StoredAutomationEvent( + id="event-uuid", created=True + ) + assert action_ids == ["action-uuid"] + assert "INSERT INTO automation_events" in cursor.executed[0][0] + assert "automation_rule_evaluations" in cursor.executed[1][0] + assert "automation_actions" in cursor.executed[2][0] + + +def test_create_automation_rule_persists_typed_definition(monkeypatch) -> None: + cursor = _CursorStub([_rule_row()]) + _install_connection_stub(monkeypatch, cursor) + + result = automation_store.create_automation_rule( + automation_store.SharedSettings(), + rule=_rule(), + created_by="discord:steering-1", + ) + + assert result.rule.id == "00000000-0000-0000-0000-000000000001" + assert result.created_by == "discord:steering-1" + assert "INSERT INTO automation_rules" in cursor.executed[0][0] + assert cursor.executed[0][1][3] == AutomationRuleOrigin.CONFIGURED.value + assert cursor.executed[0][1][6] == AutomationRuleMode.SUGGEST.value + + +def test_update_automation_rule_uses_optimistic_version(monkeypatch) -> None: + cursor = _CursorStub([_rule_row(version=2)]) + _install_connection_stub(monkeypatch, cursor) + + result = automation_store.update_automation_rule( + automation_store.SharedSettings(), + rule=_rule(version=1), + expected_version=1, + ) + + assert result is not None + assert result.rule.version == 2 + assert "AND version = %s" in cursor.executed[0][0] + assert "AND origin = %s" in cursor.executed[0][0] + assert cursor.executed[0][1][-2:] == (1, AutomationRuleOrigin.CONFIGURED.value) + + +def test_create_learned_automation_rule_loads_an_equivalent_existing_rule( + monkeypatch, +) -> None: + row = _rule_row() + row.update( + { + "origin": "learned", + "learning_key": "project-payment:v1:abc123", + "priority": -100, + } + ) + rule = AutomationRule.model_validate( + {key: value for key, value in row.items() if key != "created_by"} + ) + cursor = _CursorStub([None, row]) + _install_connection_stub(monkeypatch, cursor) + + stored_rule, created = automation_store.create_automation_rule_if_absent( + automation_store.SharedSettings(), + rule=rule, + created_by="system:project-payment-learning:action-1", + ) + + assert created is False + assert stored_rule.rule == rule + assert "ON CONFLICT DO NOTHING" in cursor.executed[0][0] + assert "learning_key" in cursor.executed[1][0] + + +def test_update_refuses_to_rewrite_a_learned_rule(monkeypatch) -> None: + cursor = _CursorStub([None]) + _install_connection_stub(monkeypatch, cursor) + row = _rule_row() + row.update({"origin": "learned", "learning_key": "project-payment:v1:abc123"}) + learned_rule = AutomationRule.model_validate( + {key: value for key, value in row.items() if key != "created_by"} + ) + + result = automation_store.update_automation_rule( + automation_store.SharedSettings(), + rule=learned_rule, + expected_version=1, + ) + + assert result is None + assert "AND origin = %s" in cursor.executed[0][0] + assert cursor.executed[0][1][-1] == AutomationRuleOrigin.CONFIGURED.value + + +def test_disable_automation_rule_preserves_history_with_new_version( + monkeypatch, +) -> None: + cursor = _CursorStub([_rule_row(version=2, enabled=False)]) + _install_connection_stub(monkeypatch, cursor) + + result = automation_store.disable_automation_rule( + automation_store.SharedSettings(), + rule_id="00000000-0000-0000-0000-000000000001", + expected_version=1, + ) + + assert result is not None + assert result.rule.enabled is False + assert "SET enabled = FALSE" in cursor.executed[0][0] + + +def test_approve_automation_action_records_typed_review_feedback(monkeypatch) -> None: + cursor = _CursorStub( + [ + _action_row( + status="approved", + review_decision="approved", + reviewed_by="discord:admin-1", + ) + ] + ) + _install_connection_stub(monkeypatch, cursor) + + action = automation_store.approve_automation_action( + automation_store.SharedSettings(), + action_id="00000000-0000-0000-0000-000000000003", + approved_by="discord:admin-1", + ) + + assert action is not None + assert action.review_decision is automation_store.AutomationReviewDecision.APPROVED + assert action.reviewed_by == "discord:admin-1" + query, params = cursor.executed[0] + assert "review_decision = %s" in query + assert "reviewed_by = %s" in query + assert params[:4] == ( + "approved", + "discord:admin-1", + "approved", + "discord:admin-1", + ) + + +def test_reject_automation_action_records_typed_review_feedback(monkeypatch) -> None: + cursor = _CursorStub( + [ + _action_row( + status="dead", + review_decision="rejected", + reviewed_by="discord:admin-1", + ) + ] + ) + _install_connection_stub(monkeypatch, cursor) + + action = automation_store.reject_automation_action( + automation_store.SharedSettings(), + action_id="00000000-0000-0000-0000-000000000003", + rejected_by="discord:admin-1", + ) + + assert action is not None + assert action.review_decision is automation_store.AutomationReviewDecision.REJECTED + assert action.reviewed_by == "discord:admin-1" + query, params = cursor.executed[0] + assert "review_decision = %s" in query + assert "AND disposition = %s" in query + assert params[:4] == ( + "dead", + "rejected", + "discord:admin-1", + "rejected_by:discord:admin-1", + ) + assert params[-1] == "suggested" + + +def test_list_approved_review_actions_returns_immutable_learning_evidence( + monkeypatch, +) -> None: + row = _action_row( + status="approved", + review_decision="approved", + reviewed_by="discord:admin-1", + ) + row.update( + { + "subject_id": "transaction-uuid", + "subject_snapshot": { + "direction": "inbound", + "currency": "GBP", + "counterparty": "Acme Ltd", + }, + } + ) + cursor = _CursorStub([row]) + _install_connection_stub(monkeypatch, cursor) + + reviews = automation_store.list_approved_automation_review_actions( + automation_store.SharedSettings(), + event_type="bank_transaction.posted.v1", + action_type="project_payment.route", + ) + + assert len(reviews) == 1 + assert reviews[0].subject_snapshot["counterparty"] == "Acme Ltd" + query, params = cursor.executed[0] + assert "action.review_decision = %s" in query + assert params[:4] == ( + "bank_transaction.posted.v1", + "project_payment.route", + "suggested", + "approved", + ) diff --git a/tests/unit/test_backend_api.py b/tests/unit/test_backend_api.py index ec4b285a..59a5def3 100644 --- a/tests/unit/test_backend_api.py +++ b/tests/unit/test_backend_api.py @@ -20,6 +20,20 @@ InMemoryTaskStore, ToolRegistry, ) +from five08.automation import ( + AutomationAction, + AutomationCondition, + AutomationConditionOperator, + AutomationReviewDecision, + AutomationRule, + AutomationRuleMode, +) +from five08.automation_store import ( + AutomationActionStatus, + StoredAutomationAction, + StoredAutomationReviewAction, + StoredAutomationRule, +) from five08.backend import api from five08.job_channels import JobPostingType, RegisteredJobPostChannel from five08.worker.masking import mask_email @@ -237,6 +251,124 @@ def _dashboard_write_session() -> api.AuthSession: ) +_PAYMENT_RULE_PROJECT_ID = "00000000-0000-0000-0000-000000000001" + + +def _payment_rule_payload(*, mode: str = "automatic") -> dict[str, object]: + return { + "project_id": _PAYMENT_RULE_PROJECT_ID, + "priority": 10, + "mode": mode, + "enabled": True, + "conditions": [ + { + "fact": "transaction.amount", + "operator": "equals", + "value": "1250.00", + }, + { + "fact": "transaction.description", + "operator": "contains", + "value": "Acme Design Sprint", + }, + { + "fact": "transaction.currency", + "operator": "equals", + "value": "GBP", + }, + ], + "actions": [ + { + "action_type": "project_payment.route", + "payload": {"project_id": _PAYMENT_RULE_PROJECT_ID}, + } + ], + } + + +def _stored_payment_rule( + *, version: int = 1, enabled: bool = True +) -> StoredAutomationRule: + return StoredAutomationRule( + rule=AutomationRule( + id="00000000-0000-0000-0000-000000000002", + project_id=_PAYMENT_RULE_PROJECT_ID, + event_type="bank_transaction.posted.v1", + priority=10, + mode=AutomationRuleMode.AUTOMATIC, + enabled=enabled, + version=version, + conditions=[ + AutomationCondition( + fact="transaction.amount", + operator=AutomationConditionOperator.EQUALS, + value="1250.00", + ), + AutomationCondition( + fact="transaction.description", + operator=AutomationConditionOperator.CONTAINS, + value="Acme Design Sprint", + ), + AutomationCondition( + fact="transaction.currency", + operator=AutomationConditionOperator.EQUALS, + value="GBP", + ), + ], + actions=[ + AutomationAction( + action_type="project_payment.route", + payload={"project_id": _PAYMENT_RULE_PROJECT_ID}, + ) + ], + ), + created_by="discord:steering-1", + ) + + +_PAYMENT_ACTION_ID = "00000000-0000-0000-0000-000000000003" + + +def _stored_payment_suggestion( + *, + status: AutomationActionStatus = AutomationActionStatus.AWAITING_REVIEW, + approved_by: str | None = None, + review_decision: AutomationReviewDecision | None = None, + reviewed_by: str | None = None, + reviewed_at: datetime | None = None, +) -> StoredAutomationReviewAction: + action = StoredAutomationAction( + id=_PAYMENT_ACTION_ID, + event_id="00000000-0000-0000-0000-000000000004", + action_type="project_payment.route", + payload={"project_id": _PAYMENT_RULE_PROJECT_ID, "amount": "1250.00"}, + mode="suggest", + disposition="suggested", + status=status, + attempts=0, + idempotency_key="project-payment-route:transaction-1:project-1", + lease_token=None, + approved_by=approved_by, + rule_project_id=_PAYMENT_RULE_PROJECT_ID, + review_decision=review_decision, + reviewed_by=reviewed_by, + reviewed_at=reviewed_at, + ) + return StoredAutomationReviewAction( + action=action, + subject_id="00000000-0000-0000-0000-000000000005", + subject_snapshot={ + "source": "erpnext", + "source_revision": "2026-07-16 12:34:56.000000", + "direction": "inbound", + "amount": "1250.00", + "currency": "GBP", + "counterparty": "Acme Ltd", + "description": "Acme Design Sprint", + }, + ) + + def test_health_handler_healthy(client: TestClient) -> None: """Health endpoint should report healthy when Redis pings.""" with patch("five08.backend.api.is_postgres_healthy", return_value=True): @@ -2376,23 +2508,505 @@ def test_dashboard_configuration_requires_admin_permission( expires_at=4_102_444_800, actor_provider=api.ActorProvider.DISCORD.value, ) - + + with ( + patch( + "five08.backend.api._current_session", + new_callable=AsyncMock, + return_value=("session-2", non_admin_session), + ), + patch("five08.backend.api.list_runtime_config") as mock_forbidden_list, + ): + forbidden_response = client.get("/dashboard/api/configuration") + + assert forbidden_response.status_code == 403 + assert forbidden_response.json()["error"] == "forbidden" + mock_forbidden_list.assert_not_called() + + +def test_dashboard_configuration_update_audits_secret_value_required( + client: TestClient, +) -> None: + session = api.AuthSession( + subject="admin-1", + email="admin@508.dev", + display_name="Admin User", + groups=["Admin"], + is_admin=True, + id_token="validated", + expires_at=4_102_444_800, + actor_provider=api.ActorProvider.DISCORD.value, + ) + + with ( + patch( + "five08.backend.api._current_session", + new_callable=AsyncMock, + return_value=("session-1", session), + ), + patch( + "five08.backend.api._write_auth_audit_event", + new_callable=AsyncMock, + ) as mock_audit, + ): + response = client.put( + "/dashboard/api/configuration/OPENAI_API_KEY", + json={"value": ""}, + ) + + assert response.status_code == 400 + assert response.json()["error"] == "secret_value_required" + mock_audit.assert_awaited_once() + audit_kwargs = mock_audit.await_args.kwargs + assert audit_kwargs["action"] == "configuration.update" + assert audit_kwargs["result"] == api.AuditResult.ERROR + assert audit_kwargs["resource_id"] == "OPENAI_API_KEY" + assert audit_kwargs["metadata"]["error"] == "secret_value_required" + + +def test_dashboard_configuration_update_audits_unknown_key( + client: TestClient, +) -> None: + session = api.AuthSession( + subject="admin-1", + email="admin@508.dev", + display_name="Admin User", + groups=["Admin"], + is_admin=True, + id_token="validated", + expires_at=4_102_444_800, + actor_provider=api.ActorProvider.DISCORD.value, + ) + + with ( + patch( + "five08.backend.api._current_session", + new_callable=AsyncMock, + return_value=("session-1", session), + ), + patch( + "five08.backend.api._write_auth_audit_event", + new_callable=AsyncMock, + ) as mock_audit, + ): + response = client.put( + "/dashboard/api/configuration/UNKNOWN_SETTING", + json={"value": "ignored"}, + ) + + assert response.status_code == 404 + assert response.json()["error"] == "unknown_configuration_key" + mock_audit.assert_awaited_once() + audit_kwargs = mock_audit.await_args.kwargs + assert audit_kwargs["action"] == "configuration.update" + assert audit_kwargs["result"] == api.AuditResult.ERROR + assert audit_kwargs["resource_id"] == "UNKNOWN_SETTING" + assert audit_kwargs["metadata"]["error"] == "unknown_configuration_key" + + +def test_dashboard_project_payment_rules_lists_typed_rules(client: TestClient) -> None: + session = api.AuthSession( + subject="admin-1", + email="admin@508.dev", + display_name="Admin User", + groups=["Admin"], + is_admin=True, + id_token="validated", + expires_at=4_102_444_800, + actor_provider=api.ActorProvider.DISCORD.value, + ) + stored_rule = _stored_payment_rule() + with ( + patch( + "five08.backend.api._current_session", + new_callable=AsyncMock, + return_value=("session-1", session), + ), + patch( + "five08.backend.api.list_automation_rules", + return_value=[stored_rule], + ) as mock_list, + ): + response = client.get( + f"/dashboard/api/project-payment-rules?project_id={_PAYMENT_RULE_PROJECT_ID}" + ) + + assert response.status_code == 200 + assert response.json()["rules"] == [ + { + "id": "00000000-0000-0000-0000-000000000002", + "project_id": _PAYMENT_RULE_PROJECT_ID, + "event_type": "bank_transaction.posted.v1", + "origin": "configured", + "learning_key": None, + "priority": 10, + "mode": "automatic", + "enabled": True, + "version": 1, + "conditions": _payment_rule_payload()["conditions"], + "actions": _payment_rule_payload()["actions"], + "created_by": "discord:steering-1", + } + ] + assert mock_list.call_args.kwargs == { + "event_type": "bank_transaction.posted.v1", + "project_id": _PAYMENT_RULE_PROJECT_ID, + } + + +def test_dashboard_project_payment_rule_create_validates_and_audits( + client: TestClient, +) -> None: + session = api.AuthSession( + subject="admin-1", + email="admin@508.dev", + display_name="Admin User", + groups=["Admin"], + is_admin=True, + id_token="validated", + expires_at=4_102_444_800, + actor_provider=api.ActorProvider.DISCORD.value, + ) + stored_rule = _stored_payment_rule() + with ( + patch( + "five08.backend.api._current_session", + new_callable=AsyncMock, + return_value=("session-1", session), + ), + patch("five08.backend.api.project_is_open", return_value=True), + patch( + "five08.backend.api.create_automation_rule", + return_value=stored_rule, + ) as mock_create, + patch( + "five08.backend.api._write_auth_audit_event", + new_callable=AsyncMock, + ) as mock_audit, + ): + response = client.post( + "/dashboard/api/project-payment-rules", + json=_payment_rule_payload(), + ) + + assert response.status_code == 201 + assert response.json()["rule"]["id"] == stored_rule.rule.id + assert mock_create.call_args.kwargs["rule"].project_id == _PAYMENT_RULE_PROJECT_ID + assert mock_create.call_args.kwargs["created_by"] == "discord:admin-1" + audit_kwargs = mock_audit.await_args.kwargs + assert audit_kwargs["action"] == "project_payment_rule.create" + assert audit_kwargs["result"] is api.AuditResult.SUCCESS + + +def test_dashboard_project_payment_rule_rejects_unsafe_automatic_rule( + client: TestClient, +) -> None: + session = api.AuthSession( + subject="admin-1", + email="admin@508.dev", + display_name="Admin User", + groups=["Admin"], + is_admin=True, + id_token="validated", + expires_at=4_102_444_800, + actor_provider=api.ActorProvider.DISCORD.value, + ) + unsafe_payload = _payment_rule_payload() + unsafe_payload["conditions"] = [ + { + "fact": "transaction.amount", + "operator": "equals", + "value": "1250.00", + } + ] + with ( + patch( + "five08.backend.api._current_session", + new_callable=AsyncMock, + return_value=("session-1", session), + ), + patch("five08.backend.api.project_is_open") as mock_project_open, + patch( + "five08.backend.api.create_automation_rule", + ) as mock_create, + ): + response = client.post( + "/dashboard/api/project-payment-rules", + json=unsafe_payload, + ) + + assert response.status_code == 400 + assert response.json() == {"error": "unsafe_automatic_project_payment_rule"} + mock_project_open.assert_not_called() + mock_create.assert_not_called() + + +def test_dashboard_project_payment_rule_update_rejects_stale_version( + client: TestClient, +) -> None: + session = api.AuthSession( + subject="admin-1", + email="admin@508.dev", + display_name="Admin User", + groups=["Admin"], + is_admin=True, + id_token="validated", + expires_at=4_102_444_800, + actor_provider=api.ActorProvider.DISCORD.value, + ) + payload = _payment_rule_payload() + payload["expected_version"] = 1 + with ( + patch( + "five08.backend.api._current_session", + new_callable=AsyncMock, + return_value=("session-1", session), + ), + patch("five08.backend.api.project_is_open", return_value=True), + patch("five08.backend.api.update_automation_rule", return_value=None), + ): + response = client.put( + "/dashboard/api/project-payment-rules/00000000-0000-0000-0000-000000000002", + json=payload, + ) + + assert response.status_code == 409 + assert response.json() == {"error": "stale_or_missing_rule"} + + +def test_dashboard_project_payment_suggestions_list_immutable_evidence( + client: TestClient, +) -> None: + session = api.AuthSession( + subject="admin-1", + email="admin@508.dev", + display_name="Admin User", + groups=["Admin"], + is_admin=True, + id_token="validated", + expires_at=4_102_444_800, + actor_provider=api.ActorProvider.DISCORD.value, + ) + suggestion = _stored_payment_suggestion() + with ( + patch( + "five08.backend.api._current_session", + new_callable=AsyncMock, + return_value=("session-1", session), + ), + patch( + "five08.backend.api.list_automation_actions_awaiting_review", + return_value=[suggestion], + ) as mock_list, + ): + response = client.get( + "/dashboard/api/project-payment-suggestions" + f"?project_id={_PAYMENT_RULE_PROJECT_ID}&limit=10" + ) + + assert response.status_code == 200 + assert response.json() == { + "suggestions": [ + { + "id": _PAYMENT_ACTION_ID, + "event_id": "00000000-0000-0000-0000-000000000004", + "action_type": "project_payment.route", + "payload": { + "project_id": _PAYMENT_RULE_PROJECT_ID, + "amount": "1250.00", + }, + "mode": "suggest", + "disposition": "suggested", + "status": "awaiting_review", + "attempts": 0, + "project_id": _PAYMENT_RULE_PROJECT_ID, + "approved_by": None, + "review_decision": None, + "reviewed_by": None, + "reviewed_at": None, + "subject_id": "00000000-0000-0000-0000-000000000005", + "subject_snapshot": { + "source": "erpnext", + "source_revision": "2026-07-16 12:34:56.000000", + "direction": "inbound", + "amount": "1250.00", + "currency": "GBP", + "counterparty": "Acme Ltd", + "description": "Acme Design Sprint", + }, + } + ] + } + assert mock_list.call_args.args == (api.settings,) + assert mock_list.call_args.kwargs == { + "event_type": "bank_transaction.posted.v1", + "project_id": _PAYMENT_RULE_PROJECT_ID, + "limit": 10, + } + + +def test_dashboard_project_payment_suggestion_approval_queues_durable_action( + client: TestClient, + app: api.FastAPI, +) -> None: + session = api.AuthSession( + subject="admin-1", + email="admin@508.dev", + display_name="Admin User", + groups=["Admin"], + is_admin=True, + id_token="validated", + expires_at=4_102_444_800, + actor_provider=api.ActorProvider.DISCORD.value, + ) + approved_review = _stored_payment_suggestion( + status=AutomationActionStatus.APPROVED, + approved_by="discord:admin-1", + review_decision=AutomationReviewDecision.APPROVED, + reviewed_by="discord:admin-1", + reviewed_at=datetime(2026, 7, 16, tzinfo=timezone.utc), + ) + approved = approved_review.action + learned_rule = api.learned_project_payment_suggestion_rule( + project_id=_PAYMENT_RULE_PROJECT_ID, + subject_snapshot=approved_review.subject_snapshot, + ) + assert learned_rule is not None + stored_learned_rule = StoredAutomationRule( + rule=learned_rule, + created_by=f"system:project-payment-learning:{_PAYMENT_ACTION_ID}", + ) + with ( + patch( + "five08.backend.api._current_session", + new_callable=AsyncMock, + return_value=("session-1", session), + ), + patch( + "five08.backend.api.approve_automation_action", + return_value=approved, + ) as mock_approve, + patch( + "five08.backend.api.get_automation_review_action", + return_value=approved_review, + ) as mock_review, + patch("five08.backend.api.project_is_open", return_value=True), + patch( + "five08.backend.api.create_automation_rule_if_absent", + return_value=(stored_learned_rule, True), + ) as mock_learn, + patch( + "five08.backend.api.enqueue_job", + return_value=api.EnqueuedJob(id="payment-action-job-1", created=True), + ) as mock_enqueue, + patch( + "five08.backend.api._write_auth_audit_event", + new_callable=AsyncMock, + ) as mock_audit, + ): + response = client.post( + f"/dashboard/api/project-payment-suggestions/{_PAYMENT_ACTION_ID}/approve" + ) + + assert response.status_code == 202 + assert response.json() == { + "status": "approved", + "action": { + "id": _PAYMENT_ACTION_ID, + "event_id": "00000000-0000-0000-0000-000000000004", + "action_type": "project_payment.route", + "payload": { + "project_id": _PAYMENT_RULE_PROJECT_ID, + "amount": "1250.00", + }, + "mode": "suggest", + "disposition": "suggested", + "status": "approved", + "attempts": 0, + "project_id": _PAYMENT_RULE_PROJECT_ID, + "approved_by": "discord:admin-1", + "review_decision": "approved", + "reviewed_by": "discord:admin-1", + "reviewed_at": "2026-07-16T00:00:00+00:00", + }, + "job": {"id": "payment-action-job-1", "created": True}, + "learning": {"status": "created", "rule_id": learned_rule.id}, + } + assert mock_approve.call_args.args == (api.settings,) + assert mock_approve.call_args.kwargs == { + "action_id": _PAYMENT_ACTION_ID, + "approved_by": "discord:admin-1", + } + assert mock_enqueue.call_args.kwargs == { + "queue": app.state.queue, + "fn": api.JOB_FUNCTIONS["execute_project_payment_automation_action_job"], + "args": (_PAYMENT_ACTION_ID,), + "settings": api.settings, + "idempotency_key": f"project-payment-action:{_PAYMENT_ACTION_ID}", + } + assert mock_review.call_args.kwargs == {"action_id": _PAYMENT_ACTION_ID} + assert mock_learn.call_args.kwargs == { + "rule": learned_rule, + "created_by": f"system:project-payment-learning:{_PAYMENT_ACTION_ID}", + } + app.state.queue.enqueue.assert_not_called() + mock_audit.assert_awaited_once_with( + action="project_payment_suggestion.approve", + result=api.AuditResult.SUCCESS, + actor_subject="admin-1", + actor_display_name="Admin User", + actor_provider=api.ActorProvider.DISCORD, + resource_type="project_payment_suggestion", + resource_id=_PAYMENT_ACTION_ID, + metadata={ + "source": "dashboard", + "project_id": _PAYMENT_RULE_PROJECT_ID, + "job_id": "payment-action-job-1", + "job_created": True, + "learning": {"status": "created", "rule_id": learned_rule.id}, + }, + ) + + +def test_dashboard_project_payment_suggestion_approval_rejects_client_mutations( + client: TestClient, +) -> None: + session = api.AuthSession( + subject="admin-1", + email="admin@508.dev", + display_name="Admin User", + groups=["Admin"], + is_admin=True, + id_token="validated", + expires_at=4_102_444_800, + actor_provider=api.ActorProvider.DISCORD.value, + ) with ( patch( "five08.backend.api._current_session", new_callable=AsyncMock, - return_value=("session-2", non_admin_session), + return_value=("session-1", session), ), - patch("five08.backend.api.list_runtime_config") as mock_forbidden_list, + patch("five08.backend.api.approve_automation_action") as mock_approve, + patch( + "five08.backend.api._write_auth_audit_event", + new_callable=AsyncMock, + ) as mock_audit, ): - forbidden_response = client.get("/dashboard/api/configuration") + response = client.post( + f"/dashboard/api/project-payment-suggestions/{_PAYMENT_ACTION_ID}/approve", + json={"amount": "1.00"}, + ) - assert forbidden_response.status_code == 403 - assert forbidden_response.json()["error"] == "forbidden" - mock_forbidden_list.assert_not_called() + assert response.status_code == 400 + assert response.json() == { + "error": "invalid_project_payment_suggestion_review_payload" + } + mock_approve.assert_not_called() + mock_audit.assert_awaited_once() -def test_dashboard_configuration_update_audits_secret_value_required( +def test_dashboard_project_payment_suggestion_rejection_is_audited( client: TestClient, ) -> None: session = api.AuthSession( @@ -2405,34 +3019,54 @@ def test_dashboard_configuration_update_audits_secret_value_required( expires_at=4_102_444_800, actor_provider=api.ActorProvider.DISCORD.value, ) - + rejected = _stored_payment_suggestion( + status=AutomationActionStatus.DEAD, + review_decision=AutomationReviewDecision.REJECTED, + reviewed_by="discord:admin-1", + reviewed_at=datetime(2026, 7, 16, tzinfo=timezone.utc), + ).action with ( patch( "five08.backend.api._current_session", new_callable=AsyncMock, return_value=("session-1", session), ), + patch( + "five08.backend.api.reject_automation_action", + return_value=rejected, + ) as mock_reject, patch( "five08.backend.api._write_auth_audit_event", new_callable=AsyncMock, ) as mock_audit, ): - response = client.put( - "/dashboard/api/configuration/OPENAI_API_KEY", - json={"value": ""}, + response = client.post( + f"/dashboard/api/project-payment-suggestions/{_PAYMENT_ACTION_ID}/reject" ) - assert response.status_code == 400 - assert response.json()["error"] == "secret_value_required" - mock_audit.assert_awaited_once() - audit_kwargs = mock_audit.await_args.kwargs - assert audit_kwargs["action"] == "configuration.update" - assert audit_kwargs["result"] == api.AuditResult.ERROR - assert audit_kwargs["resource_id"] == "OPENAI_API_KEY" - assert audit_kwargs["metadata"]["error"] == "secret_value_required" + assert response.status_code == 200 + assert response.json()["status"] == "rejected" + assert response.json()["action"]["status"] == "dead" + assert response.json()["action"]["review_decision"] == "rejected" + assert response.json()["action"]["reviewed_by"] == "discord:admin-1" + assert mock_reject.call_args.args == (api.settings,) + assert mock_reject.call_args.kwargs == { + "action_id": _PAYMENT_ACTION_ID, + "rejected_by": "discord:admin-1", + } + mock_audit.assert_awaited_once_with( + action="project_payment_suggestion.reject", + result=api.AuditResult.SUCCESS, + actor_subject="admin-1", + actor_display_name="Admin User", + actor_provider=api.ActorProvider.DISCORD, + resource_type="project_payment_suggestion", + resource_id=_PAYMENT_ACTION_ID, + metadata={"source": "dashboard", "project_id": _PAYMENT_RULE_PROJECT_ID}, + ) -def test_dashboard_configuration_update_audits_unknown_key( +def test_dashboard_project_payment_suggestion_approval_conflict_does_not_enqueue( client: TestClient, ) -> None: session = api.AuthSession( @@ -2445,31 +3079,29 @@ def test_dashboard_configuration_update_audits_unknown_key( expires_at=4_102_444_800, actor_provider=api.ActorProvider.DISCORD.value, ) - with ( patch( "five08.backend.api._current_session", new_callable=AsyncMock, return_value=("session-1", session), ), + patch( + "five08.backend.api.approve_automation_action", + return_value=None, + ), + patch("five08.backend.api.enqueue_job") as mock_enqueue, patch( "five08.backend.api._write_auth_audit_event", new_callable=AsyncMock, - ) as mock_audit, + ), ): - response = client.put( - "/dashboard/api/configuration/UNKNOWN_SETTING", - json={"value": "ignored"}, + response = client.post( + f"/dashboard/api/project-payment-suggestions/{_PAYMENT_ACTION_ID}/approve" ) - assert response.status_code == 404 - assert response.json()["error"] == "unknown_configuration_key" - mock_audit.assert_awaited_once() - audit_kwargs = mock_audit.await_args.kwargs - assert audit_kwargs["action"] == "configuration.update" - assert audit_kwargs["result"] == api.AuditResult.ERROR - assert audit_kwargs["resource_id"] == "UNKNOWN_SETTING" - assert audit_kwargs["metadata"]["error"] == "unknown_configuration_key" + assert response.status_code == 409 + assert response.json() == {"error": "suggestion_not_awaiting_review"} + mock_enqueue.assert_not_called() def test_dashboard_me_keeps_sensitive_permissions_for_admin_sso_without_token( @@ -8375,6 +9007,41 @@ def test_newsletter_sync_idempotency_key_clamps_interval_floor( assert key == "newsletter-sync:508-members:1" +@pytest.mark.asyncio +async def test_payment_recovery_duplicate_job_is_redelivered() -> None: + queue = Mock() + with patch( + "five08.backend.api.enqueue_job", + return_value=api.EnqueuedJob(id="payment-recovery-1", created=False), + ) as mock_enqueue: + result = await api._enqueue_project_payment_recovery_job( + queue, + reason="scheduler", + ) + + assert result == api.EnqueuedJob(id="payment-recovery-1", created=False) + assert ( + mock_enqueue.call_args.kwargs["fn"] + is api.JOB_FUNCTIONS["recover_project_payment_automation_job"] + ) + assert mock_enqueue.call_args.kwargs["idempotency_key"].startswith( + "project-payment-recovery:" + ) + queue.enqueue.assert_called_once_with("payment-recovery-1") + + +def test_payment_recovery_idempotency_key_clamps_interval_floor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(api.settings, "project_payment_recovery_interval_seconds", 1) + + key = api._project_payment_recovery_idempotency_key( + now=datetime(1970, 1, 1, 0, 1, 30, tzinfo=timezone.utc), + ) + + assert key == "project-payment-recovery:1" + + def test_dashboard_sync_newsletters_workflows_engineer_is_dry_run( client: TestClient, ) -> None: @@ -10621,3 +11288,283 @@ def test_tally_intake_rejects_when_form_allowlist_is_unconfigured( assert response.status_code == 403 assert response.json()["error"] == "invalid_form_id" mock_enqueue.assert_not_called() + + +def _erpnext_bank_transaction_webhook_payload() -> dict[str, object]: + return { + "doctype": "Bank Transaction", + "event_type": "bank_transaction.posted.v1", + "name": "ACC-BTN-0001", + "modified": "2026-07-16 12:34:56.000000", + "docstatus": 1, + } + + +def _erpnext_webhook_signature(body: bytes, secret: str) -> str: + return base64.b64encode( + hmac.new(secret.encode("utf-8"), body, hashlib.sha256).digest() + ).decode("ascii") + + +def test_erpnext_bank_transaction_webhook_queues_signed_reference( + client: TestClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Frappe's signed identity hint should enqueue a canonical ERP fetch job.""" + secret = "frappe-payment-secret" + monkeypatch.setattr( + api.settings, + "erpnext_bank_transaction_webhook_signing_secret", + secret, + ) + body = json.dumps(_erpnext_bank_transaction_webhook_payload()).encode("utf-8") + + with patch( + "five08.backend.api.enqueue_job", + return_value=api.EnqueuedJob(id="payment-job-1", created=True), + ) as mock_enqueue: + response = client.post( + "/webhooks/erpnext/bank-transaction", + content=body, + headers={ + "Content-Type": "application/json", + "X-Frappe-Webhook-Signature": _erpnext_webhook_signature(body, secret), + }, + ) + + assert response.status_code == 202 + assert response.json() == { + "status": "queued", + "source": "erpnext_bank_transaction", + "job_id": "payment-job-1", + "created": True, + "transaction_name": "ACC-BTN-0001", + } + assert ( + mock_enqueue.call_args.kwargs["fn"] + is api.JOB_FUNCTIONS["ingest_erpnext_bank_transaction_job"] + ) + assert mock_enqueue.call_args.kwargs["args"] == ( + "ACC-BTN-0001", + "bank_transaction.posted.v1", + "2026-07-16 12:34:56.000000", + ) + assert mock_enqueue.call_args.kwargs["idempotency_key"] == ( + "erpnext-bank-transaction:ACC-BTN-0001:bank_transaction.posted.v1:" + "2026-07-16 12:34:56.000000" + ) + + +def test_erpnext_bank_transaction_webhook_rejects_invalid_signature( + client: TestClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The dedicated ERP payment webhook must not fall back to generic auth.""" + monkeypatch.setattr( + api.settings, + "erpnext_bank_transaction_webhook_signing_secret", + "frappe-payment-secret", + ) + with patch("five08.backend.api.enqueue_job") as mock_enqueue: + response = client.post( + "/webhooks/erpnext/bank-transaction", + json=_erpnext_bank_transaction_webhook_payload(), + headers={"X-Frappe-Webhook-Signature": "not-a-signature"}, + ) + + assert response.status_code == 401 + assert response.json() == {"error": "unauthorized"} + mock_enqueue.assert_not_called() + + +def test_erpnext_bank_transaction_webhook_rejects_unsigned_bad_payload( + client: TestClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Schema validation happens after authentication and never enqueues bad data.""" + secret = "frappe-payment-secret" + monkeypatch.setattr( + api.settings, + "erpnext_bank_transaction_webhook_signing_secret", + secret, + ) + payload = _erpnext_bank_transaction_webhook_payload() + payload["doctype"] = "Sales Invoice" + body = json.dumps(payload).encode("utf-8") + + with patch("five08.backend.api.enqueue_job") as mock_enqueue: + response = client.post( + "/webhooks/erpnext/bank-transaction", + content=body, + headers={ + "Content-Type": "application/json", + "X-Frappe-Webhook-Signature": _erpnext_webhook_signature(body, secret), + }, + ) + + assert response.status_code == 400 + assert response.json()["error"] == "invalid_payload" + mock_enqueue.assert_not_called() + + +def test_erpnext_bank_transaction_webhook_reports_duplicate_job( + client: TestClient, + app: api.FastAPI, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A duplicate Frappe retry remains a successful, non-new queue response.""" + secret = "frappe-payment-secret" + monkeypatch.setattr( + api.settings, + "erpnext_bank_transaction_webhook_signing_secret", + secret, + ) + body = json.dumps(_erpnext_bank_transaction_webhook_payload()).encode("utf-8") + with ( + patch( + "five08.backend.api.enqueue_job", + return_value=api.EnqueuedJob(id="payment-job-1", created=False), + ), + patch( + "five08.backend.api.get_job", + return_value=Mock(status=api.JobStatus.QUEUED), + ), + patch("five08.backend.api.revive_dead_job") as mock_revive, + ): + response = client.post( + "/webhooks/erpnext/bank-transaction", + content=body, + headers={ + "Content-Type": "application/json", + "X-Frappe-Webhook-Signature": _erpnext_webhook_signature(body, secret), + }, + ) + + assert response.status_code == 202 + assert response.json()["created"] is False + app.state.queue.enqueue.assert_called_once_with("payment-job-1") + mock_revive.assert_not_called() + + +@pytest.mark.parametrize( + "status", + [api.JobStatus.RUNNING, api.JobStatus.SUCCEEDED], +) +def test_erpnext_bank_transaction_webhook_duplicate_non_dead_job_is_not_revived( + client: TestClient, + app: api.FastAPI, + monkeypatch: pytest.MonkeyPatch, + status: api.JobStatus, +) -> None: + """Normal duplicate delivery retains the existing actor-side idempotency.""" + secret = "frappe-payment-secret" + monkeypatch.setattr( + api.settings, + "erpnext_bank_transaction_webhook_signing_secret", + secret, + ) + body = json.dumps(_erpnext_bank_transaction_webhook_payload()).encode("utf-8") + + with ( + patch( + "five08.backend.api.enqueue_job", + return_value=api.EnqueuedJob(id="payment-job-1", created=False), + ), + patch( + "five08.backend.api.get_job", + return_value=Mock(status=status), + ), + patch("five08.backend.api.revive_dead_job") as mock_revive, + ): + response = client.post( + "/webhooks/erpnext/bank-transaction", + content=body, + headers={ + "Content-Type": "application/json", + "X-Frappe-Webhook-Signature": _erpnext_webhook_signature(body, secret), + }, + ) + + assert response.status_code == 202 + app.state.queue.enqueue.assert_called_once_with("payment-job-1") + mock_revive.assert_not_called() + + +def test_erpnext_bank_transaction_webhook_revives_a_dead_ingest_job( + client: TestClient, + app: api.FastAPI, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A duplicate signed hint can restart an exhausted canonical ingest.""" + secret = "frappe-payment-secret" + monkeypatch.setattr( + api.settings, + "erpnext_bank_transaction_webhook_signing_secret", + secret, + ) + body = json.dumps(_erpnext_bank_transaction_webhook_payload()).encode("utf-8") + + with ( + patch( + "five08.backend.api.enqueue_job", + return_value=api.EnqueuedJob(id="payment-job-1", created=False), + ), + patch( + "five08.backend.api.get_job", + return_value=Mock(status=api.JobStatus.DEAD), + ), + patch("five08.backend.api.revive_dead_job", return_value=True) as mock_revive, + ): + response = client.post( + "/webhooks/erpnext/bank-transaction", + content=body, + headers={ + "Content-Type": "application/json", + "X-Frappe-Webhook-Signature": _erpnext_webhook_signature(body, secret), + }, + ) + + assert response.status_code == 202 + app.state.queue.enqueue.assert_called_once_with("payment-job-1") + assert mock_revive.call_args.args == (api.settings, "payment-job-1") + assert mock_revive.call_args.kwargs == { + "expected_job_type": "ingest_erpnext_bank_transaction_job" + } + + +def test_erpnext_bank_transaction_webhook_skips_redelivery_when_revival_loses_race( + client: TestClient, + app: api.FastAPI, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Only the duplicate that wins the dead-to-queued transition redelivers.""" + secret = "frappe-payment-secret" + monkeypatch.setattr( + api.settings, + "erpnext_bank_transaction_webhook_signing_secret", + secret, + ) + body = json.dumps(_erpnext_bank_transaction_webhook_payload()).encode("utf-8") + + with ( + patch( + "five08.backend.api.enqueue_job", + return_value=api.EnqueuedJob(id="payment-job-1", created=False), + ), + patch( + "five08.backend.api.get_job", + return_value=Mock(status=api.JobStatus.DEAD), + ), + patch("five08.backend.api.revive_dead_job", return_value=False), + ): + response = client.post( + "/webhooks/erpnext/bank-transaction", + content=body, + headers={ + "Content-Type": "application/json", + "X-Frappe-Webhook-Signature": _erpnext_webhook_signature(body, secret), + }, + ) + + assert response.status_code == 202 + app.state.queue.enqueue.assert_not_called() diff --git a/tests/unit/test_dashboard_html.py b/tests/unit/test_dashboard_html.py index 9736b41b..53ca7d51 100644 --- a/tests/unit/test_dashboard_html.py +++ b/tests/unit/test_dashboard_html.py @@ -28,6 +28,7 @@ def test_dashboard_html_does_not_cache_missing_bundle( assert "Built dashboard" in html assert "frontend bundle has not been built" not in html assert "/dashboard/api/me" in html + assert "/dashboard/payments" in html def test_dashboard_assets_mount_serves_bundle_built_after_startup( diff --git a/tests/unit/test_discord_bot_client.py b/tests/unit/test_discord_bot_client.py index 72858555..d388eea3 100644 --- a/tests/unit/test_discord_bot_client.py +++ b/tests/unit/test_discord_bot_client.py @@ -97,3 +97,37 @@ def test_request_omits_json_argument_when_payload_is_none() -> None: timeout=10.0, verify=default_ca_bundle_path(), ) + + +def test_post_project_payment_notification_posts_stable_outbox_identity() -> None: + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b'{"status": "sent", "message_id": "123"}' + mock_response.json.return_value = {"status": "sent", "message_id": "123"} + + with patch( + "five08.clients.discord_bot.requests.request", + return_value=mock_response, + ) as mock_request: + result = DiscordBotClient( + "http://discord-bot.internal", "secret" + ).post_project_payment_notification( + notification_id="00000000-0000-0000-0000-000000000001", + lease_token="00000000-0000-0000-0000-000000000002", + ) + + assert result == {"status": "sent", "message_id": "123"} + mock_request.assert_called_once_with( + method="POST", + url="http://discord-bot.internal/internal/project-payments/notify", + headers={ + "Content-Type": "application/json", + "X-API-Secret": "secret", + }, + json={ + "notification_id": "00000000-0000-0000-0000-000000000001", + "lease_token": "00000000-0000-0000-0000-000000000002", + }, + timeout=10.0, + verify=default_ca_bundle_path(), + ) diff --git a/tests/unit/test_erpnext_client.py b/tests/unit/test_erpnext_client.py index 37ae3894..9c66c49b 100644 --- a/tests/unit/test_erpnext_client.py +++ b/tests/unit/test_erpnext_client.py @@ -972,3 +972,17 @@ def request( "in", ["TEST-PROJ-001", "TEST-PROJ-002"], ] in or_filters + + +def test_get_bank_transaction_uses_canonical_bank_transaction_resource() -> None: + client = CaptureERPNextClient({"data": {"name": "ACC-BTN-0001"}}) + + result = client.get_bank_transaction("ACC-BTN-0001") + + assert result == {"name": "ACC-BTN-0001"} + assert client.calls[-1] == { + "method": "GET", + "path": "/api/resource/Bank%20Transaction/ACC-BTN-0001", + "params": None, + "payload": None, + } diff --git a/tests/unit/test_internal_api.py b/tests/unit/test_internal_api.py index 73097dbf..0b873fdd 100644 --- a/tests/unit/test_internal_api.py +++ b/tests/unit/test_internal_api.py @@ -14,6 +14,7 @@ InternalAPIRoutes, MemberAgreementRoleRequest, PostJobLeadRequest, + ProjectPaymentNotificationRequest, ) @@ -175,6 +176,73 @@ async def test_post_job_lead_returns_unavailable_without_jobs_cog( assert status_code == 503 assert result == {"error": "jobs_cog_unavailable"} + @pytest.mark.asyncio + async def test_project_payment_notification_delegates_to_projects_cog( + self, + internal_api_routes, + ): + """Payment delivery must reuse project cog live-channel checks.""" + projects_cog = Mock() + projects_cog.post_project_payment_notification = AsyncMock( + return_value=({"status": "sent", "message_id": "message-1"}, 200) + ) + internal_api_routes.bot.get_cog.return_value = projects_cog + payload = ProjectPaymentNotificationRequest( + notification_id="00000000-0000-0000-0000-000000000001", + lease_token="00000000-0000-0000-0000-000000000002", + ) + + ( + result, + status_code, + ) = await internal_api_routes._post_project_payment_notification(payload) + + assert status_code == 200 + assert result == {"status": "sent", "message_id": "message-1"} + projects_cog.post_project_payment_notification.assert_awaited_once_with( + notification_id="00000000-0000-0000-0000-000000000001", + worker_lease_token="00000000-0000-0000-0000-000000000002", + ) + + @pytest.mark.asyncio + async def test_project_payment_notification_returns_unavailable_without_projects_cog( + self, + internal_api_routes, + ): + internal_api_routes.bot.get_cog.return_value = None + + ( + result, + status_code, + ) = await internal_api_routes._post_project_payment_notification( + ProjectPaymentNotificationRequest( + notification_id="00000000-0000-0000-0000-000000000001", + lease_token="00000000-0000-0000-0000-000000000002", + ) + ) + + assert status_code == 503 + assert result == {"error": "projects_cog_unavailable"} + + def test_project_payment_notification_request_rejects_forged_delivery_fields(self): + """The internal API accepts only a durable outbox identity.""" + with pytest.raises(ValueError, match="Extra inputs are not permitted"): + ProjectPaymentNotificationRequest.model_validate( + { + "notification_id": "00000000-0000-0000-0000-000000000001", + "lease_token": "00000000-0000-0000-0000-000000000002", + "amount": "999999.99", + "channel_id": "forged-channel", + } + ) + + def test_project_payment_notification_request_requires_active_worker_lease(self): + """A notification identity alone is not an internal send capability.""" + with pytest.raises(ValueError, match="lease_token"): + ProjectPaymentNotificationRequest.model_validate( + {"notification_id": "00000000-0000-0000-0000-000000000001"} + ) + @pytest.mark.asyncio async def test_list_job_channels_delegates_to_jobs_cog(self, internal_api_routes): """Internal channel metadata should come from the jobs cog.""" diff --git a/tests/unit/test_project_discord_channels.py b/tests/unit/test_project_discord_channels.py new file mode 100644 index 00000000..1f0bee2f --- /dev/null +++ b/tests/unit/test_project_discord_channels.py @@ -0,0 +1,255 @@ +"""Tests for durable private project Discord channel mappings.""" + +from __future__ import annotations + +from contextlib import contextmanager + +import pytest + +import five08.project_discord_channels as project_channels + + +class _CursorStub: + def __init__(self, rows: list[dict | None]) -> None: + self.rows = list(rows) + self.executed: list[tuple[str, tuple]] = [] + + def __enter__(self) -> "_CursorStub": + return self + + def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001 + return None + + def execute(self, query: str, params: tuple) -> None: + self.executed.append((query, params)) + + def fetchone(self) -> dict | None: + return self.rows.pop(0) if self.rows else None + + def fetchall(self) -> list[dict]: + rows = [row for row in self.rows if row is not None] + self.rows.clear() + return rows + + +class _ConnectionStub: + def __init__(self, cursor: _CursorStub) -> None: + self._cursor = cursor + + def cursor(self, row_factory=None) -> _CursorStub: # noqa: ARG002 + return self._cursor + + def __enter__(self) -> "_ConnectionStub": + return self + + def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001 + return None + + +def _install_connection_stub(monkeypatch, cursor: _CursorStub) -> None: + @contextmanager + def _connection(): + yield _ConnectionStub(cursor) + + monkeypatch.setattr( + project_channels, "get_postgres_connection", lambda _: _connection() + ) + + +def _mapping(*, project_id: str = "project-1") -> dict: + return { + "id": "mapping-1", + "project_id": project_id, + "guild_id": "guild-1", + "channel_id": "channel-1", + "channel_name": "private-project", + "active": True, + "registered_by_discord_user_id": "user-1", + "last_verified_at": None, + "last_verification_error": None, + } + + +def test_register_project_channel_creates_new_mapping(monkeypatch) -> None: + cursor = _CursorStub([_mapping()]) + _install_connection_stub(monkeypatch, cursor) + + mapping, created = project_channels.register_project_discord_channel( + project_channels.SharedSettings(), + project_id="project-1", + guild_id="guild-1", + channel_id="channel-1", + channel_name="private-project", + registered_by_discord_user_id="user-1", + ) + + assert created is True + assert mapping.project_id == "project-1" + assert "INSERT INTO project_discord_channels" in cursor.executed[0][0] + assert ( + "ON CONFLICT (guild_id, channel_id) WHERE active IS TRUE" + in cursor.executed[0][0] + ) + + +def test_register_project_channel_does_not_reassign_other_project(monkeypatch) -> None: + cursor = _CursorStub([None, _mapping(project_id="other-project")]) + _install_connection_stub(monkeypatch, cursor) + + with pytest.raises(project_channels.ProjectDiscordChannelConflict): + project_channels.register_project_discord_channel( + project_channels.SharedSettings(), + project_id="project-1", + guild_id="guild-1", + channel_id="channel-1", + channel_name="private-project", + registered_by_discord_user_id="user-1", + ) + + assert len(cursor.executed) == 2 + + +def test_register_project_channel_is_idempotent_for_same_project(monkeypatch) -> None: + cursor = _CursorStub([None, _mapping(), _mapping()]) + _install_connection_stub(monkeypatch, cursor) + + mapping, created = project_channels.register_project_discord_channel( + project_channels.SharedSettings(), + project_id="project-1", + guild_id="guild-1", + channel_id="channel-1", + channel_name="renamed-private-project", + registered_by_discord_user_id="user-2", + ) + + assert created is False + assert mapping.channel_name == "private-project" + assert "UPDATE project_discord_channels" in cursor.executed[2][0] + + +def test_list_active_project_channels_returns_only_rows_from_query(monkeypatch) -> None: + cursor = _CursorStub([_mapping(), _mapping(project_id="project-1")]) + _install_connection_stub(monkeypatch, cursor) + + result = project_channels.list_active_project_discord_channels( + project_channels.SharedSettings(), project_id="project-1" + ) + + assert [channel.channel_id for channel in result] == ["channel-1", "channel-1"] + assert "active IS TRUE" in cursor.executed[0][0] + + +def _delivery_row( + *, + project_discord_channel_id: str = "00000000-0000-0000-0000-000000000011", + status: str = "sent", + discord_message_id: str | None = "message-1", +) -> dict: + return { + "project_discord_channel_id": project_discord_channel_id, + "status": status, + "discord_message_id": discord_message_id, + } + + +def test_claim_project_payment_discord_delivery_creates_a_new_lease( + monkeypatch, +) -> None: + cursor = _CursorStub([{"discord_message_id": None}]) + _install_connection_stub(monkeypatch, cursor) + + result = project_channels.claim_project_payment_discord_delivery( + project_channels.SharedSettings(), + notification_id="00000000-0000-0000-0000-000000000001", + project_discord_channel_id="00000000-0000-0000-0000-000000000011", + worker_lease_token="00000000-0000-0000-0000-000000000021", + ) + + assert ( + result.status + is project_channels.ProjectPaymentDiscordDeliveryClaimStatus.CLAIMED + ) + assert result.lease_token is not None + assert "ON CONFLICT (notification_id) DO NOTHING" in cursor.executed[0][0] + assert "project_payment_notification_outbox" in cursor.executed[0][0] + assert "outbox.lease_token = %s::uuid" in cursor.executed[0][0] + + +def test_claim_project_payment_discord_delivery_returns_durable_sent_receipt( + monkeypatch, +) -> None: + cursor = _CursorStub([None, None, _delivery_row()]) + _install_connection_stub(monkeypatch, cursor) + + result = project_channels.claim_project_payment_discord_delivery( + project_channels.SharedSettings(), + notification_id="00000000-0000-0000-0000-000000000001", + project_discord_channel_id="00000000-0000-0000-0000-000000000011", + worker_lease_token="00000000-0000-0000-0000-000000000021", + ) + + assert ( + result.status + is project_channels.ProjectPaymentDiscordDeliveryClaimStatus.ALREADY_DELIVERED + ) + assert result.discord_message_id == "message-1" + assert "status = 'failed'" in cursor.executed[1][0] + + +def test_claim_project_payment_discord_delivery_reclaims_failed_lease( + monkeypatch, +) -> None: + cursor = _CursorStub([None, {"discord_message_id": None}]) + _install_connection_stub(monkeypatch, cursor) + + result = project_channels.claim_project_payment_discord_delivery( + project_channels.SharedSettings(), + notification_id="00000000-0000-0000-0000-000000000001", + project_discord_channel_id="00000000-0000-0000-0000-000000000011", + worker_lease_token="00000000-0000-0000-0000-000000000021", + ) + + assert ( + result.status + is project_channels.ProjectPaymentDiscordDeliveryClaimStatus.CLAIMED + ) + assert "status = 'sending'" in cursor.executed[1][0] + + +def test_failed_project_payment_discord_delivery_cannot_overwrite_sent_receipt( + monkeypatch, +) -> None: + cursor = _CursorStub([]) + _install_connection_stub(monkeypatch, cursor) + + project_channels.mark_project_payment_discord_delivery_failed( + project_channels.SharedSettings(), + notification_id="00000000-0000-0000-0000-000000000001", + project_discord_channel_id="00000000-0000-0000-0000-000000000011", + error="temporary failure", + lease_token="00000000-0000-0000-0000-000000000001", + ) + + assert "AND lock_token = %s::uuid" in cursor.executed[0][0] + + +def test_renew_project_payment_discord_delivery_lease_requires_mapping_and_token( + monkeypatch, +) -> None: + cursor = _CursorStub([{"notification_id": "notification-1"}]) + _install_connection_stub(monkeypatch, cursor) + + renewed = project_channels.renew_project_payment_discord_delivery_lease( + project_channels.SharedSettings(), + notification_id="00000000-0000-0000-0000-000000000001", + project_discord_channel_id="00000000-0000-0000-0000-000000000011", + worker_lease_token="00000000-0000-0000-0000-000000000022", + lease_token="00000000-0000-0000-0000-000000000021", + ) + + assert renewed is True + query, params = cursor.executed[0] + assert "project_payment_discord_deliveries.project_discord_channel_id" in query + assert "project_payment_discord_deliveries.lock_token" in query + assert "outbox.lease_token = %s::uuid" in query + assert params[-1] == "00000000-0000-0000-0000-000000000022" diff --git a/tests/unit/test_project_payments.py b/tests/unit/test_project_payments.py new file mode 100644 index 00000000..1346ea18 --- /dev/null +++ b/tests/unit/test_project_payments.py @@ -0,0 +1,732 @@ +"""Tests for ERPNext Bank Transaction normalization and rule facts.""" + +from datetime import datetime, timezone +from decimal import Decimal +from unittest.mock import Mock + +import pytest + +import five08.project_payments as project_payments +from five08.project_payments import ( + PaymentDirection, + automatic_project_payment_rule_is_safe, + erpnext_bank_transaction_to_input, + learned_project_payment_suggestion_rule, + payment_automation_facts, + payment_automation_subject_snapshot, + project_payment_evaluation_for_execution, + project_payment_match_method_for_action, + project_payment_notification_idempotency_key, + project_payment_rule_for_evaluation, +) +from five08.automation import ( + AutomationAction, + AutomationCondition, + AutomationConditionOperator, + AutomationEventInput, + AutomationRule, + AutomationRuleMode, + AutomationRuleOrigin, + evaluate_automation_rules, +) + + +def _install_cursor(monkeypatch, cursor: Mock) -> None: + connection = Mock() + connection.__enter__ = Mock(return_value=connection) + connection.__exit__ = Mock(return_value=None) + cursor_context = Mock() + cursor_context.__enter__ = Mock(return_value=cursor) + cursor_context.__exit__ = Mock(return_value=None) + connection.cursor.return_value = cursor_context + monkeypatch.setattr( + project_payments, + "get_postgres_connection", + lambda _settings: connection, + ) + + +def _bank_transaction(**overrides: object) -> dict[str, object]: + transaction: dict[str, object] = { + "name": "ACC-BTN-0001", + "modified": "2026-07-16T12:34:56+00:00", + "docstatus": 1, + "date": "2026-07-16T00:00:00+00:00", + "deposit": "1020.55", + "withdrawal": "0", + "currency": "USD", + "bank_account": "Main Bank", + "party": "Acme Ltd", + "description": "Invoice payment", + "reference_number": "WIRE-123", + "payment_entries": [{"payment_entry": "PE-0001"}], + } + transaction.update(overrides) + return transaction + + +def test_erpnext_bank_transaction_normalizes_inbound_money_as_decimal() -> None: + result = erpnext_bank_transaction_to_input(_bank_transaction()) + + assert result is not None + assert result.direction is PaymentDirection.INBOUND + assert result.amount == Decimal("1020.55") + assert result.transaction_id is None + assert result.reconciliation_entries == [{"payment_entry": "PE-0001"}] + assert payment_automation_facts(result) == { + "transaction": { + "direction": "inbound", + "amount": "1020.55", + "currency": "USD", + "description": "Invoice payment", + "counterparty": "Acme Ltd", + "reference_number": "WIRE-123", + "bank_account": "Main Bank", + "has_reconciliation": True, + } + } + + +def test_erpnext_bank_transaction_prefers_bank_statement_counterparty() -> None: + result = erpnext_bank_transaction_to_input( + _bank_transaction( + bank_party_name="Acme Bank Statement Name", + party="Reconciliation-derived Customer", + ) + ) + + assert result is not None + assert result.counterparty == "Acme Bank Statement Name" + + +def test_approved_receipt_teaches_only_a_deterministic_review_suggestion() -> None: + snapshot = { + "direction": "inbound", + "currency": "gbp", + "counterparty": " Acme Ltd ", + "reference_number": "WIRE-123", + "description": "Invoice payment", + } + + rule = learned_project_payment_suggestion_rule( + project_id="00000000-0000-0000-0000-000000000001", + subject_snapshot=snapshot, + ) + duplicate = learned_project_payment_suggestion_rule( + project_id="00000000-0000-0000-0000-000000000001", + subject_snapshot=snapshot, + ) + + assert rule is not None + assert duplicate == rule + assert rule.origin is AutomationRuleOrigin.LEARNED + assert rule.mode is AutomationRuleMode.SUGGEST + assert rule.priority == -100 + assert rule.learning_key is not None + assert "Acme" not in rule.learning_key + assert [condition.model_dump(mode="json") for condition in rule.conditions] == [ + {"fact": "transaction.direction", "operator": "equals", "value": "inbound"}, + { + "fact": "transaction.counterparty", + "operator": "contains", + "value": "Acme Ltd", + }, + {"fact": "transaction.currency", "operator": "equals", "value": "GBP"}, + ] + assert rule.actions[0].payload == { + "project_id": "00000000-0000-0000-0000-000000000001" + } + + +@pytest.mark.parametrize( + "snapshot", + [ + {"direction": "outbound", "currency": "GBP", "counterparty": "Acme Ltd"}, + {"direction": "inbound", "currency": "GB", "counterparty": "Acme Ltd"}, + {"direction": "inbound", "currency": "GBP", "counterparty": "--"}, + ], +) +def test_learning_rejects_weak_or_nonreceipt_evidence(snapshot: dict[str, str]) -> None: + assert ( + learned_project_payment_suggestion_rule( + project_id="00000000-0000-0000-0000-000000000001", + subject_snapshot=snapshot, + ) + is None + ) + + +@pytest.mark.parametrize( + ("approved_by", "origin", "expected"), + [ + ( + None, + "configured", + project_payments.ProjectPaymentMatchMethod.CONFIGURED_RULE, + ), + ( + "discord:admin-1", + "learned", + project_payments.ProjectPaymentMatchMethod.LEARNED_SUGGESTION, + ), + ( + "discord:admin-1", + "configured", + project_payments.ProjectPaymentMatchMethod.MANUAL, + ), + ], +) +def test_payment_allocation_match_method_retains_learning_provenance( + approved_by: str | None, + origin: str, + expected: project_payments.ProjectPaymentMatchMethod, +) -> None: + assert ( + project_payment_match_method_for_action( + approved_by=approved_by, + rule_origin=origin, + ) + is expected + ) + + +def test_newer_bank_transaction_revision_only_supersedes_different_automatic_evidence( + monkeypatch, +) -> None: + cursor = Mock() + cursor.fetchone.return_value = {"id": "transaction-uuid", "created": False} + _install_cursor(monkeypatch, cursor) + transaction = erpnext_bank_transaction_to_input(_bank_transaction()) + assert transaction is not None + + result = project_payments.upsert_bank_transaction( + project_payments.SharedSettings(), + transaction, + ) + + assert result.id == "transaction-uuid" + assert result.accepted is True + supersede_query, supersede_params = cursor.execute.call_args_list[1].args + assert "UPDATE project_payment_allocations" in supersede_query + assert "match_method = %s" in supersede_query + assert "source_revision' IS DISTINCT FROM %s" in supersede_query + assert supersede_params == ( + "superseded", + "source_revision_changed", + "transaction-uuid", + "confirmed", + "configured_rule", + "2026-07-16T12:34:56+00:00", + ) + + +def test_notification_idempotency_is_scoped_to_channel_registration() -> None: + first_key = project_payment_notification_idempotency_key( + payment_transaction_id="payment-1", + allocation_id="allocation-1", + project_discord_channel_id="mapping-old", + ) + re_registered_key = project_payment_notification_idempotency_key( + payment_transaction_id="payment-1", + allocation_id="allocation-1", + project_discord_channel_id="mapping-new", + ) + + assert first_key != re_registered_key + assert first_key.endswith(":mapping-old") + assert re_registered_key.endswith(":mapping-new") + + +def test_notification_create_handles_the_durable_allocation_channel_unique_key( + monkeypatch, +) -> None: + cursor = Mock() + cursor.fetchone.side_effect = [None, {"id": "existing-notification"}] + _install_cursor(monkeypatch, cursor) + + notification_id, created = project_payments.create_project_payment_notification( + project_payments.SharedSettings(), + allocation_id="00000000-0000-0000-0000-000000000001", + project_id="00000000-0000-0000-0000-000000000002", + project_discord_channel_id="00000000-0000-0000-0000-000000000003", + channel_id="discord-channel", + idempotency_key="payment-received:v2:existing", + payload={}, + ) + + assert (notification_id, created) == ("existing-notification", False) + insert_query, _insert_params = cursor.execute.call_args_list[0].args + lookup_query, lookup_params = cursor.execute.call_args_list[1].args + assert "ON CONFLICT DO NOTHING" in insert_query + assert "allocation_id = %s::uuid" in lookup_query + assert lookup_params == ( + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000003", + ) + + +def test_erpnext_bank_transaction_normalizes_outbound_without_receipt_semantics() -> ( + None +): + result = erpnext_bank_transaction_to_input( + _bank_transaction(deposit="0", withdrawal="42.10") + ) + + assert result is not None + assert result.direction is PaymentDirection.OUTBOUND + assert result.amount == Decimal("42.10") + + +def test_erpnext_bank_transaction_ignores_draft_and_zero_value_documents() -> None: + assert erpnext_bank_transaction_to_input(_bank_transaction(docstatus=0)) is None + assert ( + erpnext_bank_transaction_to_input( + _bank_transaction(deposit="0", withdrawal="0") + ) + is None + ) + + +def test_erpnext_bank_transaction_rejects_ambiguous_or_negative_amounts() -> None: + with pytest.raises(ValueError, match="both deposit and withdrawal"): + erpnext_bank_transaction_to_input( + _bank_transaction(deposit="50", withdrawal="25") + ) + with pytest.raises(ValueError, match="negative amount"): + erpnext_bank_transaction_to_input(_bank_transaction(deposit="-1")) + + +def test_amount_only_automatic_payment_rule_is_downgraded_to_suggestion() -> None: + rule = AutomationRule( + id="rule-1", + project_id="00000000-0000-0000-0000-000000000001", + event_type="bank_transaction.posted.v1", + mode=AutomationRuleMode.AUTOMATIC, + conditions=[ + AutomationCondition( + fact="transaction.amount", + operator=AutomationConditionOperator.EQUALS, + value="1250.00", + ) + ], + actions=[AutomationAction(action_type="project_payment.route")], + ) + + assert automatic_project_payment_rule_is_safe(rule) is False + assert project_payment_rule_for_evaluation(rule).mode is AutomationRuleMode.SUGGEST + + +def test_named_and_amount_matched_automatic_payment_rule_is_safe() -> None: + rule = AutomationRule( + id="rule-1", + project_id="00000000-0000-0000-0000-000000000001", + event_type="bank_transaction.posted.v1", + mode=AutomationRuleMode.AUTOMATIC, + conditions=[ + AutomationCondition( + fact="transaction.amount", + operator=AutomationConditionOperator.EQUALS, + value="1250.00", + ), + AutomationCondition( + fact="transaction.description", + operator=AutomationConditionOperator.CONTAINS, + value="Acme Design Sprint", + ), + AutomationCondition( + fact="transaction.currency", + operator=AutomationConditionOperator.EQUALS, + value="USD", + ), + ], + actions=[ + AutomationAction( + action_type="project_payment.route", + payload={"project_id": "00000000-0000-0000-0000-000000000001"}, + ) + ], + ) + + assert automatic_project_payment_rule_is_safe(rule) is True + + +def test_learned_payment_rule_cannot_become_automatic() -> None: + rule = AutomationRule( + id="rule-1", + project_id="00000000-0000-0000-0000-000000000001", + event_type="bank_transaction.posted.v1", + origin=AutomationRuleOrigin.LEARNED, + learning_key="project-payment:v1:abc123", + mode=AutomationRuleMode.AUTOMATIC, + conditions=[ + AutomationCondition( + fact="transaction.amount", + operator=AutomationConditionOperator.EQUALS, + value="1250.00", + ), + AutomationCondition( + fact="transaction.counterparty", + operator=AutomationConditionOperator.CONTAINS, + value="Acme Ltd", + ), + AutomationCondition( + fact="transaction.currency", + operator=AutomationConditionOperator.EQUALS, + value="GBP", + ), + ], + actions=[ + AutomationAction( + action_type="project_payment.route", + payload={"project_id": "00000000-0000-0000-0000-000000000001"}, + ) + ], + ) + + assert automatic_project_payment_rule_is_safe(rule) is False + assert project_payment_rule_for_evaluation(rule).mode is AutomationRuleMode.SUGGEST + + +@pytest.mark.parametrize( + ("rule_project_id", "action_project_id"), + [ + (None, "00000000-0000-0000-0000-000000000001"), + ("not-a-uuid", "00000000-0000-0000-0000-000000000001"), + ( + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + ), + ("00000000-0000-0000-0000-000000000001", None), + ], +) +def test_automatic_payment_rule_with_invalid_project_scope_is_downgraded( + rule_project_id: str | None, + action_project_id: str | None, +) -> None: + payload = {"project_id": action_project_id} if action_project_id else {} + rule = AutomationRule( + id="rule-1", + project_id=rule_project_id, + event_type="bank_transaction.posted.v1", + mode=AutomationRuleMode.AUTOMATIC, + conditions=[ + AutomationCondition( + fact="transaction.has_reconciliation", + operator=AutomationConditionOperator.EQUALS, + value=True, + ) + ], + actions=[ + AutomationAction( + action_type="project_payment.route", + payload=payload, + ) + ], + ) + + assert automatic_project_payment_rule_is_safe(rule) is False + assert project_payment_rule_for_evaluation(rule).mode is AutomationRuleMode.SUGGEST + + +def test_payment_notification_recovery_lists_stale_sending_rows(monkeypatch) -> None: + cursor = Mock() + cursor.fetchall.return_value = [{"id": "stale-notification"}] + _install_cursor(monkeypatch, cursor) + + result = project_payments.list_retryable_project_payment_notification_ids( + project_payments.SharedSettings(), + stale_after_seconds=90, + ) + + assert result == ["stale-notification"] + query, params = cursor.execute.call_args.args + assert "status = %s" in query + assert params[2:4] == ("sending", 90) + + +def test_notification_failure_only_updates_active_sending_lease(monkeypatch) -> None: + cursor = Mock() + _install_cursor(monkeypatch, cursor) + + project_payments.mark_project_payment_notification_failed( + project_payments.SharedSettings(), + notification_id="notification-1", + error="temporary failure", + lease_token="00000000-0000-0000-0000-000000000001", + ) + + query, params = cursor.execute.call_args.args + assert "AND status = %s" in query + assert params[-2:] == ("sending", "00000000-0000-0000-0000-000000000001") + + +def test_notification_ineligibility_blocks_superseded_allocations_after_lease_abandons( + monkeypatch, +) -> None: + cursor = Mock() + cursor.fetchone.return_value = {"id": "notification-1"} + _install_cursor(monkeypatch, cursor) + + blocked = project_payments.block_project_payment_notification_if_ineligible( + project_payments.SharedSettings(), + notification_id="00000000-0000-0000-0000-000000000001", + stale_after_seconds=90, + ) + + assert blocked is True + query, params = cursor.execute.call_args.args + assert "outbox.status = %s" in query + assert "outbox.locked_at < NOW()" in query + assert "allocation.status = %s" in query + assert params[-3:] == ("sending", 90, "confirmed") + + +def test_bot_delivery_context_requires_the_active_worker_outbox_lease( + monkeypatch, +) -> None: + cursor = Mock() + cursor.fetchone.return_value = None + _install_cursor(monkeypatch, cursor) + + context = project_payments.get_project_payment_notification_delivery_context( + project_payments.SharedSettings(), + notification_id="00000000-0000-0000-0000-000000000001", + worker_lease_token="00000000-0000-0000-0000-000000000002", + ) + + assert context is None + query, params = cursor.execute.call_args.args + assert "outbox.status = %s" in query + assert "outbox.lease_token = %s::uuid" in query + assert params[1:3] == ("sending", "00000000-0000-0000-0000-000000000002") + assert params[-1] == "confirmed" + + +def test_notification_source_context_requires_the_active_worker_lease( + monkeypatch, +) -> None: + cursor = Mock() + cursor.fetchone.return_value = { + "notification_id": "00000000-0000-0000-0000-000000000001", + "bank_transaction_id": "00000000-0000-0000-0000-000000000002", + "source": "erpnext", + "external_id": "ACC-BTN-0001", + "allocation_status": "confirmed", + "allocation_source_revision": "2026-07-16T12:34:56+00:00", + "current_source_revision": "2026-07-16T12:34:56+00:00", + } + _install_cursor(monkeypatch, cursor) + + context = project_payments.get_project_payment_notification_source_context( + project_payments.SharedSettings(), + notification_id="00000000-0000-0000-0000-000000000001", + lease_token="00000000-0000-0000-0000-000000000003", + ) + + assert context is not None + assert context.external_id == "ACC-BTN-0001" + assert ( + context.allocation_status + is project_payments.ProjectPaymentAllocationStatus.CONFIRMED + ) + assert context.allocation_source_revision == "2026-07-16T12:34:56+00:00" + query, params = cursor.execute.call_args.args + assert "outbox.status = %s" in query + assert "outbox.lease_token = %s::uuid" in query + assert params == ( + "00000000-0000-0000-0000-000000000001", + "sending", + "00000000-0000-0000-0000-000000000003", + ) + + +def test_payment_snapshot_preserves_canonical_revision_and_money() -> None: + transaction = erpnext_bank_transaction_to_input(_bank_transaction()) + assert transaction is not None + + assert payment_automation_subject_snapshot(transaction) == { + "source": "erpnext", + "external_id": "ACC-BTN-0001", + "source_revision": "2026-07-16T12:34:56+00:00", + "transaction_id": None, + "direction": "inbound", + "amount": "1020.55", + "currency": "USD", + "bank_account": "Main Bank", + "counterparty": "Acme Ltd", + "description": "Invoice payment", + "reference_number": "WIRE-123", + "posted_at": "2026-07-16T00:00:00+00:00", + "reconciliation_entries": [{"payment_entry": "PE-0001"}], + } + + +def test_competing_top_priority_automatic_routes_become_suggestions() -> None: + event = AutomationEventInput( + event_id="event-1", + event_type="bank_transaction.posted.v1", + source="erpnext", + subject_id="transaction-1", + occurred_at=datetime.now(timezone.utc), + facts={"transaction": {"direction": "inbound"}}, + ) + rules = [ + AutomationRule( + id=f"rule-{suffix}", + project_id=f"00000000-0000-0000-0000-00000000000{index}", + event_type=event.event_type, + priority=10, + mode=AutomationRuleMode.AUTOMATIC, + conditions=[ + AutomationCondition( + fact="transaction.direction", + operator=AutomationConditionOperator.EQUALS, + value="inbound", + ) + ], + actions=[ + AutomationAction( + action_type="project_payment.route", + payload={ + "project_id": f"00000000-0000-0000-0000-00000000000{index}" + }, + ) + ], + ) + for index, suffix in ((1, "a"), (2, "b")) + ] + evaluation = evaluate_automation_rules(event, rules) + + resolved = project_payment_evaluation_for_execution(evaluation) + + assert {proposal.disposition.value for proposal in resolved.action_proposals} == { + "suggested" + } + + +def test_unique_highest_priority_automatic_route_remains_ready() -> None: + event = AutomationEventInput( + event_id="event-1", + event_type="bank_transaction.posted.v1", + source="erpnext", + subject_id="transaction-1", + occurred_at=datetime.now(timezone.utc), + facts={"transaction": {"direction": "inbound"}}, + ) + rules = [ + AutomationRule( + id=f"rule-{priority}", + project_id=f"00000000-0000-0000-0000-00000000000{priority}", + event_type=event.event_type, + priority=priority, + mode=AutomationRuleMode.AUTOMATIC, + conditions=[ + AutomationCondition( + fact="transaction.direction", + operator=AutomationConditionOperator.EQUALS, + value="inbound", + ) + ], + actions=[ + AutomationAction( + action_type="project_payment.route", + payload={ + "project_id": f"00000000-0000-0000-0000-00000000000{priority}" + }, + ) + ], + ) + for priority in (20, 10) + ] + evaluation = evaluate_automation_rules(event, rules) + + resolved = project_payment_evaluation_for_execution(evaluation) + + assert [proposal.disposition.value for proposal in resolved.action_proposals] == [ + "ready", + "suggested", + ] + + +def _claimed_action_row(*, current_rule_version: int = 1) -> dict[str, object]: + return { + "id": "00000000-0000-0000-0000-000000000100", + "action_type": "project_payment.route", + "payload": {"project_id": "00000000-0000-0000-0000-000000000001"}, + "action_mode": "automatic", + "disposition": "ready", + "approved_by": None, + "rule_version": 1, + "rule_project_id": "00000000-0000-0000-0000-000000000001", + "current_rule_version": current_rule_version, + "current_rule_mode": "automatic", + "current_rule_enabled": True, + "subject_id": "00000000-0000-0000-0000-000000000200", + "event_subject_revision": "revision-1", + "subject_snapshot": { + "source": "erpnext", + "external_id": "ACC-BTN-0001", + "source_revision": "revision-1", + "direction": "inbound", + "amount": "1250.00", + "currency": "USD", + }, + } + + +def test_atomic_action_blocks_a_superseded_rule_before_money_is_written( + monkeypatch, +) -> None: + cursor = Mock() + cursor.fetchone.side_effect = [ + _claimed_action_row(current_rule_version=2), + {"id": "00000000-0000-0000-0000-000000000100"}, + ] + _install_cursor(monkeypatch, cursor) + + result = project_payments.apply_project_payment_automation_action( + project_payments.SharedSettings(), + action_id="00000000-0000-0000-0000-000000000100", + lease_token="00000000-0000-0000-0000-000000000300", + ) + + assert result.status.value == "blocked" + assert result.reason == "rule_version_superseded_or_not_automatic" + queries = [call.args[0] for call in cursor.execute.call_args_list] + assert not any( + "INSERT INTO project_payment_allocations" in query for query in queries + ) + + +def test_atomic_action_blocks_a_superseded_erp_revision_before_money_is_written( + monkeypatch, +) -> None: + cursor = Mock() + cursor.fetchone.side_effect = [ + _claimed_action_row(), + { + "id": "00000000-0000-0000-0000-000000000200", + "source": "erpnext", + "external_id": "ACC-BTN-0001", + "source_revision": "revision-2", + "direction": "inbound", + "amount": "1250.00", + "currency": "USD", + }, + {"id": "00000000-0000-0000-0000-000000000100"}, + ] + _install_cursor(monkeypatch, cursor) + + result = project_payments.apply_project_payment_automation_action( + project_payments.SharedSettings(), + action_id="00000000-0000-0000-0000-000000000100", + lease_token="00000000-0000-0000-0000-000000000300", + ) + + assert result.status.value == "blocked" + assert result.reason == "payment_source_revision_superseded" + queries = [call.args[0] for call in cursor.execute.call_args_list] + assert any("FOR UPDATE OF action, rule" in query for query in queries) + assert not any( + "INSERT INTO project_payment_allocations" in query for query in queries + ) diff --git a/tests/unit/test_projects.py b/tests/unit/test_projects.py index 67c37b40..2a34123f 100644 --- a/tests/unit/test_projects.py +++ b/tests/unit/test_projects.py @@ -1,6 +1,7 @@ from __future__ import annotations -from unittest.mock import patch +from datetime import datetime, timezone +from unittest.mock import Mock, patch import five08.projects as projects_module import pytest @@ -16,6 +17,8 @@ normalize_match_text, parse_project_wiki_tables, project_viewer_emails_for_discord, + ProjectInput, + upsert_project, ) from five08.settings import SharedSettings @@ -123,12 +126,68 @@ def cursor(self, *args: object, **kwargs: object) -> Cursor: assert emails == ["member@508.dev", "member@example.com"] -def test_mark_missing_erpnext_open_projects_skips_empty_seen_ids() -> None: - with patch("five08.projects.get_postgres_connection") as mock_connect: +def test_mark_missing_erpnext_open_projects_closes_cached_projects_for_empty_sync() -> ( + None +): + cursor = Mock() + cursor.rowcount = 2 + connection = Mock() + connection.__enter__ = Mock(return_value=connection) + connection.__exit__ = Mock(return_value=None) + cursor_context = Mock() + cursor_context.__enter__ = Mock(return_value=cursor) + cursor_context.__exit__ = Mock(return_value=None) + connection.cursor.return_value = cursor_context + + with patch("five08.projects.get_postgres_connection", return_value=connection): result = mark_missing_erpnext_open_projects_not_open(SharedSettings(), []) - assert result == 0 - mock_connect.assert_not_called() + assert result == 2 + query, params = cursor.execute.call_args.args + assert "NOT (pei.external_id = ANY" in query + assert params[-1] == [] + + +def test_stale_erpnext_project_payload_cannot_overwrite_newer_cache_or_roster() -> None: + """A delayed Open fetch must not reopen an action-time Closed refresh.""" + project_id = "00000000-0000-0000-0000-000000000001" + cursor = Mock() + cursor.fetchone.side_effect = [ + {"project_id": project_id}, + None, + ] + connection = Mock() + connection.__enter__ = Mock(return_value=connection) + connection.__exit__ = Mock(return_value=None) + cursor_context = Mock() + cursor_context.__enter__ = Mock(return_value=cursor) + cursor_context.__exit__ = Mock(return_value=None) + connection.cursor.return_value = cursor_context + stale_open = ProjectInput( + source="erpnext", + external_id="PROJ-001", + display_name="Project", + source_status="Open", + source_modified_at=datetime(2026, 7, 16, tzinfo=timezone.utc), + ) + + with patch("five08.projects.get_postgres_connection", return_value=connection): + result = upsert_project(SharedSettings(), stale_open) + + assert result == project_id + update_query, update_params = cursor.execute.call_args_list[2].args + assert "OR %s > projects.source_modified_at" in update_query + assert update_params[-2:] == ( + stale_open.source_modified_at, + stale_open.source_modified_at, + ) + executed_queries = [call.args[0] for call in cursor.execute.call_args_list] + assert not any( + "INSERT INTO project_roster_members" in query for query in executed_queries + ) + assert not any( + "INSERT INTO project_external_ids" in query for query in executed_queries + ) def test_fetch_outline_document_wraps_transport_errors() -> None: diff --git a/tests/unit/test_projects_cog.py b/tests/unit/test_projects_cog.py new file mode 100644 index 00000000..ea11b8a0 --- /dev/null +++ b/tests/unit/test_projects_cog.py @@ -0,0 +1,544 @@ +"""Focused unit tests for project payment channel command helpers.""" + +from __future__ import annotations + +from collections.abc import Sequence +from datetime import datetime, timezone +from decimal import Decimal +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import AsyncMock, Mock + +import pytest + +from five08.discord_bot.cogs.projects import ProjectsCog +from five08.project_discord_channels import ( + ProjectPaymentDiscordDeliveryClaim, + ProjectPaymentDiscordDeliveryClaimStatus, +) + + +class _PaymentChannel: + def __init__(self, messages: list[object] | None = None) -> None: + self.messages = messages or [] + self.history_calls = 0 + self.send_calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] + + async def history(self, **_kwargs: object): # noqa: ANN201 + self.history_calls += 1 + for message in self.messages: + yield message + + async def send(self, *args: object, **kwargs: object) -> object: + self.send_calls.append((args, kwargs)) + return SimpleNamespace(id="sent-message-1") + + +class _GuildSequence(Sequence[SimpleNamespace]): + """Small stand-in for discord.py's non-list ``SequenceProxy``.""" + + def __init__(self, values: list[SimpleNamespace]) -> None: + self.values = values + + def __getitem__(self, index: int) -> SimpleNamespace: + return self.values[index] + + def __len__(self) -> int: + return len(self.values) + + +def _payment_context() -> dict[str, object]: + return { + "notification_id": "00000000-0000-0000-0000-000000000001", + "project_discord_channel_id": "00000000-0000-0000-0000-000000000002", + "project_id": "00000000-0000-0000-0000-000000000003", + "guild_id": "1", + "channel_id": "2", + "allocation_id": "00000000-0000-0000-0000-000000000004", + "amount": Decimal("1250.00"), + "currency": "USD", + "posted_at": datetime(2026, 7, 16, tzinfo=timezone.utc), + "bank_transaction_id": "00000000-0000-0000-0000-000000000005", + } + + +def _payment_delivery_kwargs() -> dict[str, str]: + return { + "notification_id": "00000000-0000-0000-0000-000000000001", + "worker_lease_token": "00000000-0000-0000-0000-000000000010", + } + + +def _payment_cog( + monkeypatch, + channel: _PaymentChannel, + *, + contexts: list[SimpleNamespace | None] | None = None, +) -> ProjectsCog: + bot = Mock() + bot.get_guild.return_value = SimpleNamespace() + bot.guilds = [SimpleNamespace(id=1)] + cog = ProjectsCog(bot) + default_context = SimpleNamespace(**_payment_context()) + remaining_contexts = list( + contexts or [default_context, default_context, default_context] + ) + + def _get_context(*_args: object, **_kwargs: object) -> SimpleNamespace | None: + if remaining_contexts: + return remaining_contexts.pop(0) + return default_context + + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.get_project_payment_notification_delivery_context", + _get_context, + ) + monkeypatch.setattr(cog, "_private_channel_error", lambda *_args: None) + + async def _resolve_channel( + *_args: object, + **_kwargs: object, + ) -> tuple[_PaymentChannel, bool]: + return channel, False + + monkeypatch.setattr(cog, "_resolve_text_channel", _resolve_channel) + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.record_project_discord_channel_verification", + lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.renew_project_payment_discord_delivery_lease", + lambda *_args, **_kwargs: True, + ) + return cog + + +def test_project_payment_message_has_stable_hidden_idempotency_marker() -> None: + content = ProjectsCog._payment_message_content( + notification_id="00000000-0000-0000-0000-000000000001", + amount="1250", + currency="USD", + posted_at="2026-07-16T00:00:00+00:00", + ) + + assert "USD 1,250.00" in content + assert ( + "" + in content + ) + assert "Payment received" in content + + +def test_project_channel_validation_requires_read_message_history() -> None: + default_role = object() + bot_member = object() + guild = SimpleNamespace(id=1, default_role=default_role, me=bot_member) + + class _Channel: + guild = SimpleNamespace(id=1) + + @staticmethod + def permissions_for(member: object) -> SimpleNamespace: + if member is default_role: + return SimpleNamespace(view_channel=False) + return SimpleNamespace( + view_channel=True, + send_messages=True, + read_message_history=False, + ) + + assert ( + ProjectsCog._private_channel_error(cast(Any, guild), cast(Any, _Channel())) + == "missing_read_message_history_permission" + ) + + +def test_project_channel_guild_fallback_accepts_a_discord_sequence_proxy_shape( + monkeypatch, +) -> None: + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.settings.discord_server_id", "" + ) + bot = Mock() + bot.guilds = _GuildSequence([SimpleNamespace(id=1)]) + + assert ProjectsCog(bot)._is_configured_guild(1) is True + assert ProjectsCog(bot)._is_configured_guild(2) is False + + +@pytest.mark.asyncio +async def test_project_channel_autocomplete_uses_local_open_project_ids( + monkeypatch, +) -> None: + bot = Mock() + bot.guilds = [SimpleNamespace(id=1)] + cog = ProjectsCog(bot) + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.list_dashboard_projects", + lambda *_args, **_kwargs: [ + { + "id": "00000000-0000-0000-0000-000000000001", + "display_name": "Acme Design Sprint", + "customer": "Acme", + "erpnext_project_id": "PROJ-001", + } + ], + ) + + choices = await cog.project_channel_autocomplete( + SimpleNamespace( + guild=SimpleNamespace(id=1), + user=SimpleNamespace(roles=[SimpleNamespace(name="Steering Committee")]), + ), + "acme", + ) + + assert [(choice.name, choice.value) for choice in choices] == [ + ("Acme Design Sprint — Acme", "00000000-0000-0000-0000-000000000001") + ] + + +@pytest.mark.asyncio +async def test_project_channel_autocomplete_does_not_leak_project_names_to_members( + monkeypatch, +) -> None: + bot = Mock() + bot.guilds = [SimpleNamespace(id=1)] + cog = ProjectsCog(bot) + list_projects = Mock() + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.list_dashboard_projects", + list_projects, + ) + + choices = await cog.project_channel_autocomplete( + SimpleNamespace( + guild=SimpleNamespace(id=1), + user=SimpleNamespace(roles=[SimpleNamespace(name="Member")]), + ), + "acme", + ) + + assert choices == [] + list_projects.assert_not_called() + + +@pytest.mark.asyncio +async def test_project_channel_registration_rejects_a_different_guild( + monkeypatch, +) -> None: + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.settings.discord_server_id", "1" + ) + bot = Mock() + bot.guilds = [SimpleNamespace(id=1)] + cog = ProjectsCog(bot) + target_channel = SimpleNamespace(id=99, name="private-payments") + monkeypatch.setattr(cog, "_target_text_channel", lambda *_args: target_channel) + list_projects = Mock() + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.list_dashboard_projects", + list_projects, + ) + interaction = SimpleNamespace( + guild=SimpleNamespace(id=999), + response=SimpleNamespace(send_message=AsyncMock()), + user=SimpleNamespace( + id=123, + roles=[SimpleNamespace(name="Steering Committee")], + ), + ) + + await ProjectsCog.register_project_channel.callback( + cog, + interaction, + project="00000000-0000-0000-0000-000000000001", + ) + + interaction.response.send_message.assert_awaited_once() + assert "configured server" in interaction.response.send_message.call_args.args[0] + list_projects.assert_not_called() + + +@pytest.mark.asyncio +async def test_project_payment_delivery_rejects_a_cross_guild_mapping( + monkeypatch, +) -> None: + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.settings.discord_server_id", "2" + ) + channel = _PaymentChannel() + cog = _payment_cog(monkeypatch, channel) + claim = Mock() + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.claim_project_payment_discord_delivery", + claim, + ) + + result, status_code = await cog.post_project_payment_notification( + **_payment_delivery_kwargs() + ) + + assert status_code == 403 + assert result == {"error": "payment_channel_wrong_guild"} + claim.assert_not_called() + + +@pytest.mark.asyncio +async def test_project_payment_delivery_returns_durable_bot_receipt_without_sending( + monkeypatch, +) -> None: + channel = _PaymentChannel() + cog = _payment_cog(monkeypatch, channel) + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.claim_project_payment_discord_delivery", + lambda *_args, **_kwargs: ProjectPaymentDiscordDeliveryClaim( + status=ProjectPaymentDiscordDeliveryClaimStatus.ALREADY_DELIVERED, + discord_message_id="existing-message-1", + ), + ) + + result, status_code = await cog.post_project_payment_notification( + **_payment_delivery_kwargs() + ) + + assert status_code == 200 + assert result == { + "status": "already_delivered", + "message_id": "existing-message-1", + } + assert channel.history_calls == 0 + assert channel.send_calls == [] + + +@pytest.mark.asyncio +async def test_project_payment_delivery_persists_marker_recovery_receipt( + monkeypatch, +) -> None: + channel = _PaymentChannel( + [ + SimpleNamespace( + id="existing-message-1", + content=( + "" + ), + ) + ] + ) + cog = _payment_cog(monkeypatch, channel) + persisted: dict[str, object] = {} + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.claim_project_payment_discord_delivery", + lambda *_args, **_kwargs: ProjectPaymentDiscordDeliveryClaim( + status=ProjectPaymentDiscordDeliveryClaimStatus.CLAIMED, + lease_token="lease-1", + ), + ) + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.mark_project_payment_discord_delivery_sent", + lambda *_args, **kwargs: persisted.update(kwargs) or True, + ) + + result, status_code = await cog.post_project_payment_notification( + **_payment_delivery_kwargs() + ) + + assert status_code == 200 + assert result["status"] == "already_delivered" + assert persisted["discord_message_id"] == "existing-message-1" + assert ( + persisted["project_discord_channel_id"] + == "00000000-0000-0000-0000-000000000002" + ) + assert channel.send_calls == [] + + +@pytest.mark.asyncio +async def test_project_payment_delivery_persists_receipt_after_discord_send( + monkeypatch, +) -> None: + channel = _PaymentChannel() + cog = _payment_cog(monkeypatch, channel) + claimed: dict[str, object] = {} + persisted: dict[str, object] = {} + renewals: list[dict[str, str]] = [] + + def renew_worker_bound_delivery_lease( + _settings: object, + *, + notification_id: str, + project_discord_channel_id: str, + worker_lease_token: str, + lease_token: str, + ) -> bool: + renewals.append( + { + "notification_id": notification_id, + "project_discord_channel_id": project_discord_channel_id, + "worker_lease_token": worker_lease_token, + "lease_token": lease_token, + } + ) + return True + + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.renew_project_payment_discord_delivery_lease", + renew_worker_bound_delivery_lease, + ) + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.claim_project_payment_discord_delivery", + lambda *_args, **kwargs: ( + claimed.update(kwargs) + or ProjectPaymentDiscordDeliveryClaim( + status=ProjectPaymentDiscordDeliveryClaimStatus.CLAIMED, + lease_token="lease-1", + ) + ), + ) + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.mark_project_payment_discord_delivery_sent", + lambda *_args, **kwargs: persisted.update(kwargs) or True, + ) + + result, status_code = await cog.post_project_payment_notification( + **_payment_delivery_kwargs() + ) + + assert status_code == 200 + assert result == {"status": "sent", "message_id": "sent-message-1"} + assert ( + claimed["project_discord_channel_id"] == "00000000-0000-0000-0000-000000000002" + ) + assert claimed["worker_lease_token"] == "00000000-0000-0000-0000-000000000010" + assert len(renewals) == 2 + assert all( + renewal["worker_lease_token"] == "00000000-0000-0000-0000-000000000010" + for renewal in renewals + ) + assert persisted["discord_message_id"] == "sent-message-1" + assert ( + persisted["project_discord_channel_id"] + == "00000000-0000-0000-0000-000000000002" + ) + assert len(channel.send_calls) == 1 + assert "USD 1,250.00" in str(channel.send_calls[0][0][0]) + nonce = str(channel.send_calls[0][1]["nonce"]) + assert nonce.startswith("pp-") + assert len(nonce) <= 25 + + +@pytest.mark.asyncio +async def test_project_payment_delivery_rejects_missing_or_revoked_canonical_context( + monkeypatch, +) -> None: + channel = _PaymentChannel() + cog = _payment_cog(monkeypatch, channel, contexts=[None]) + claim = Mock() + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.claim_project_payment_discord_delivery", + claim, + ) + + result, status_code = await cog.post_project_payment_notification( + **_payment_delivery_kwargs() + ) + + assert status_code == 409 + assert result == {"error": "payment_notification_not_eligible"} + claim.assert_not_called() + assert channel.send_calls == [] + + +@pytest.mark.asyncio +async def test_project_payment_delivery_blocks_when_mapping_revokes_after_claim( + monkeypatch, +) -> None: + initial_context = SimpleNamespace(**_payment_context()) + channel = _PaymentChannel() + cog = _payment_cog(monkeypatch, channel, contexts=[initial_context, None]) + released: dict[str, object] = {} + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.claim_project_payment_discord_delivery", + lambda *_args, **_kwargs: ProjectPaymentDiscordDeliveryClaim( + status=ProjectPaymentDiscordDeliveryClaimStatus.CLAIMED, + lease_token="lease-1", + ), + ) + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.mark_project_payment_discord_delivery_failed", + lambda *_args, **kwargs: released.update(kwargs) or True, + ) + + result, status_code = await cog.post_project_payment_notification( + **_payment_delivery_kwargs() + ) + + assert status_code == 409 + assert result == {"error": "payment_notification_not_eligible"} + assert ( + released["project_discord_channel_id"] + == initial_context.project_discord_channel_id + ) + assert released["error"] == "notification_no_longer_eligible" + assert channel.history_calls == 0 + assert channel.send_calls == [] + + +@pytest.mark.asyncio +async def test_project_payment_delivery_returns_retryable_status_when_guild_is_unready( + monkeypatch, +) -> None: + channel = _PaymentChannel() + cog = _payment_cog(monkeypatch, channel) + cog.bot.get_guild.return_value = None + + result, status_code = await cog.post_project_payment_notification( + **_payment_delivery_kwargs() + ) + + assert status_code == 503 + assert result == {"error": "guild_not_ready"} + assert channel.send_calls == [] + + +@pytest.mark.asyncio +async def test_project_payment_delivery_returns_retryable_status_when_lease_is_lost( + monkeypatch, +) -> None: + channel = _PaymentChannel() + cog = _payment_cog(monkeypatch, channel) + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.claim_project_payment_discord_delivery", + lambda *_args, **_kwargs: ProjectPaymentDiscordDeliveryClaim( + status=ProjectPaymentDiscordDeliveryClaimStatus.CLAIMED, + lease_token="lease-1", + ), + ) + monkeypatch.setattr( + "five08.discord_bot.cogs.projects.renew_project_payment_discord_delivery_lease", + lambda *_args, **_kwargs: False, + ) + + result, status_code = await cog.post_project_payment_notification( + **_payment_delivery_kwargs() + ) + + assert status_code == 503 + assert result == {"error": "payment_notification_delivery_lease_lost"} + assert channel.history_calls == 0 + assert channel.send_calls == [] + + +@pytest.mark.asyncio +async def test_project_payment_delivery_accepts_only_the_notification_and_lease( + monkeypatch, +) -> None: + cog = _payment_cog(monkeypatch, _PaymentChannel(), contexts=[None]) + notification_method = cast(Any, cog.post_project_payment_notification) + + with pytest.raises(TypeError, match="project_id"): + await notification_method( + **_payment_delivery_kwargs(), + project_id="forged-project", + ) diff --git a/tests/unit/test_runtime_config.py b/tests/unit/test_runtime_config.py index def2ceee..f3ed94e0 100644 --- a/tests/unit/test_runtime_config.py +++ b/tests/unit/test_runtime_config.py @@ -300,6 +300,8 @@ def test_runtime_config_numeric_bounds_are_preserved( "DISCORD_LOGS_WEBHOOK_URL", "NEWSLETTER_SYNC_ENABLED", "NEWSLETTER_SYNC_INTERVAL_SECONDS", + "PROJECT_PAYMENT_AUTOMATION_ENABLED", + "PROJECT_PAYMENT_RECOVERY_INTERVAL_SECONDS", ], ) def test_startup_bound_runtime_config_is_restart_required(key: str) -> None: @@ -334,6 +336,32 @@ def test_newsletter_settings_are_dashboard_configurable(key: str) -> None: assert definition.category in {"Newsletter", "Mailbox"} +@pytest.mark.parametrize( + "key", + [ + "ERPNEXT_BANK_TRANSACTION_WEBHOOK_SIGNING_SECRET", + "PROJECT_PAYMENT_AUTOMATION_ENABLED", + "PROJECT_PAYMENT_NOTIFICATIONS_ENABLED", + "PROJECT_PAYMENT_RECOVERY_INTERVAL_SECONDS", + ], +) +def test_project_payment_settings_are_dashboard_configurable(key: str) -> None: + definition = runtime_config_definition_for_key(key) + + assert definition is not None + assert definition.category == "Projects" + + +def test_project_payment_recovery_interval_has_sixty_second_floor() -> None: + definition = runtime_config_definition_for_key( + "PROJECT_PAYMENT_RECOVERY_INTERVAL_SECONDS" + ) + assert definition is not None + + with pytest.raises(ValueError, match="greater than or equal to 60"): + coerce_runtime_config_value(definition, "59") + + def test_core_crm_auth_and_mailbox_settings_are_not_dashboard_configurable() -> None: assert runtime_config_definition_for_key("ESPO_BASE_URL") is None assert runtime_config_definition_for_key("ESPO_API_KEY") is None diff --git a/tests/unit/test_shared_queue.py b/tests/unit/test_shared_queue.py index 83db72be..f81557f0 100644 --- a/tests/unit/test_shared_queue.py +++ b/tests/unit/test_shared_queue.py @@ -1,8 +1,8 @@ """Unit tests for shared queue helpers.""" -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch -from five08.queue import JobStatus, _parse_status, enqueue_job +from five08.queue import JobStatus, _parse_status, enqueue_job, revive_dead_job from five08.settings import SharedSettings @@ -32,3 +32,31 @@ def test_parse_status_handles_unknown_values() -> None: mock_warning.assert_called_once_with( "Unknown job status from DB: %s", "unexpected-status" ) + + +def test_revive_dead_job_requires_matching_dead_row() -> None: + """Only a matching dead job can be atomically reset for redelivery.""" + cursor = Mock() + cursor.fetchone.return_value = {"id": "job-1"} + connection = MagicMock() + connection.__enter__.return_value = connection + connection.cursor.return_value.__enter__.return_value = cursor + + with patch("five08.queue.get_postgres_connection", return_value=connection): + revived = revive_dead_job( + SharedSettings(), + "job-1", + expected_job_type="ingest_erpnext_bank_transaction_job", + ) + + assert revived is True + query, params = cursor.execute.call_args.args + assert "status = %s" in query + assert "attempts = 0" in query + assert "AND type = %s" in query + assert params == ( + JobStatus.QUEUED, + "job-1", + "ingest_erpnext_bank_transaction_job", + JobStatus.DEAD, + ) diff --git a/tests/unit/test_worker_erpnext_bank_transaction_sync.py b/tests/unit/test_worker_erpnext_bank_transaction_sync.py new file mode 100644 index 00000000..a6ba41c8 --- /dev/null +++ b/tests/unit/test_worker_erpnext_bank_transaction_sync.py @@ -0,0 +1,284 @@ +"""Tests for canonical ERPNext Bank Transaction ingestion.""" + +from __future__ import annotations + +from decimal import Decimal + +from five08.automation import ( + AutomationAction, + AutomationActionDisposition, + AutomationCondition, + AutomationConditionOperator, + AutomationRule, + AutomationRuleMode, +) +from five08.automation_store import StoredAutomationEvent +from five08.project_payments import ( + BANK_TRANSACTION_POSTED_EVENT, + PaymentDirection, + StoredBankTransaction, +) +from five08.worker import erpnext_bank_transaction_sync as payment_sync + + +class FakeERPNextClient: + def __init__(self, document: dict[str, object] | Exception) -> None: + self.document = document + self.requested_names: list[str] = [] + self.closed = False + + def get_bank_transaction(self, transaction_name: str) -> dict[str, object]: + self.requested_names.append(transaction_name) + if isinstance(self.document, Exception): + raise self.document + return self.document + + def close(self) -> None: + self.closed = True + + +def _processor( + client: FakeERPNextClient, +) -> payment_sync.ERPNextBankTransactionProcessor: + processor = payment_sync.ERPNextBankTransactionProcessor.__new__( + payment_sync.ERPNextBankTransactionProcessor + ) + processor.client = client + processor.settings = object() + return processor + + +def _submitted_inbound_document() -> dict[str, object]: + return { + "name": "ACC-BTN-0001", + "modified": "2026-07-16T12:34:56+00:00", + "docstatus": 1, + "date": "2026-07-16T00:00:00+00:00", + "deposit": "1250.00", + "withdrawal": "0", + "currency": "USD", + "bank_account": "Main Bank", + "description": "Acme invoice payment", + "payment_entries": [ + {"payment_document": "Sales Invoice", "payment_entry": "PE-1"} + ], + } + + +def _safe_automatic_rule() -> AutomationRule: + project_id = "00000000-0000-0000-0000-000000000001" + return AutomationRule( + id="rule-1", + project_id=project_id, + event_type=BANK_TRANSACTION_POSTED_EVENT, + mode=AutomationRuleMode.AUTOMATIC, + conditions=[ + AutomationCondition( + fact="transaction.description", + operator=AutomationConditionOperator.CONTAINS, + value="Acme", + ), + AutomationCondition( + fact="transaction.amount", + operator=AutomationConditionOperator.EQUALS, + value="1250.00", + ), + AutomationCondition( + fact="transaction.currency", + operator=AutomationConditionOperator.EQUALS, + value="USD", + ), + ], + actions=[ + AutomationAction( + action_type="project_payment.route", + payload={"project_id": project_id}, + ) + ], + ) + + +def test_ingest_fetches_canonical_document_and_persists_typed_event( + monkeypatch, +) -> None: + client = FakeERPNextClient(_submitted_inbound_document()) + processor = _processor(client) + captured: dict[str, object] = {} + + monkeypatch.setattr( + payment_sync, + "upsert_bank_transaction", + lambda _settings, transaction: ( + captured.update({"transaction": transaction}) + or StoredBankTransaction(id="local-transaction", created=True) + ), + ) + monkeypatch.setattr( + payment_sync, "list_enabled_automation_rules", lambda *_args, **_kwargs: [] + ) + monkeypatch.setattr( + payment_sync, + "persist_automation_event_and_evaluation", + lambda _settings, *, event, event_key, evaluation: ( + captured.update( + {"event": event, "event_key": event_key, "evaluation": evaluation} + ) + or (StoredAutomationEvent(id="event-uuid", created=True), ["action-1"]) + ), + ) + + result = processor.ingest_bank_transaction( + transaction_name="ACC-BTN-0001", + source_revision="webhook-revision", + ) + + transaction = captured["transaction"] + assert getattr(transaction, "amount") == Decimal("1250.00") + assert getattr(transaction, "direction") is PaymentDirection.INBOUND + event = captured["event"] + assert getattr(event, "subject_id") == "local-transaction" + assert getattr(event, "facts")["transaction"]["amount"] == "1250.00" + assert result["status"] == "ingested" + assert result["action_ids"] == ["action-1"] + assert client.requested_names == ["ACC-BTN-0001"] + assert client.closed is True + + +def test_ingest_ignores_non_submitted_canonical_document(monkeypatch) -> None: + document = _submitted_inbound_document() + document["docstatus"] = 0 + client = FakeERPNextClient(document) + processor = _processor(client) + + monkeypatch.setattr( + payment_sync, + "upsert_bank_transaction", + lambda *_args: (_ for _ in ()).throw(AssertionError("must not persist")), + ) + + result = processor.ingest_bank_transaction(transaction_name="ACC-BTN-0001") + + assert result == { + "status": "ignored", + "reason": "bank_transaction_not_submitted_or_empty", + "transaction_name": "ACC-BTN-0001", + } + assert client.closed is True + + +def test_ingest_duplicate_event_returns_retryable_action_ids(monkeypatch) -> None: + client = FakeERPNextClient(_submitted_inbound_document()) + processor = _processor(client) + + monkeypatch.setattr( + payment_sync, + "upsert_bank_transaction", + lambda *_args: StoredBankTransaction(id="local-transaction", created=False), + ) + monkeypatch.setattr( + payment_sync, "list_enabled_automation_rules", lambda *_args, **_kwargs: [] + ) + monkeypatch.setattr( + payment_sync, + "persist_automation_event_and_evaluation", + lambda *_args, **_kwargs: ( + StoredAutomationEvent(id="event-uuid", created=False), + [], + ), + ) + monkeypatch.setattr( + payment_sync, + "list_retryable_automation_action_ids_for_event", + lambda *_args, **_kwargs: ["failed-action"], + ) + result = processor.ingest_bank_transaction(transaction_name="ACC-BTN-0001") + + assert result["event_created"] is False + assert result["action_ids"] == ["failed-action"] + assert client.closed is True + + +def test_ingest_ignores_a_stale_canonical_revision(monkeypatch) -> None: + client = FakeERPNextClient(_submitted_inbound_document()) + processor = _processor(client) + monkeypatch.setattr( + payment_sync, + "upsert_bank_transaction", + lambda *_args: StoredBankTransaction( + id="local-transaction", created=False, accepted=False + ), + ) + monkeypatch.setattr( + payment_sync, + "list_enabled_automation_rules", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("must not evaluate") + ), + ) + + result = processor.ingest_bank_transaction(transaction_name="ACC-BTN-0001") + + assert result == { + "status": "ignored", + "reason": "bank_transaction_revision_superseded", + "transaction_name": "ACC-BTN-0001", + "transaction_id": "local-transaction", + } + assert client.closed is True + + +def test_ingest_downgrades_unorderable_erp_revision_to_suggestion(monkeypatch) -> None: + document = _submitted_inbound_document() + document["modified"] = "not-an-erpnext-timestamp" + client = FakeERPNextClient(document) + processor = _processor(client) + captured: dict[str, object] = {} + + monkeypatch.setattr( + payment_sync, + "upsert_bank_transaction", + lambda *_args: StoredBankTransaction(id="local-transaction", created=True), + ) + monkeypatch.setattr( + payment_sync, + "list_enabled_automation_rules", + lambda *_args, **_kwargs: [_safe_automatic_rule()], + ) + monkeypatch.setattr( + payment_sync, + "persist_automation_event_and_evaluation", + lambda _settings, *, event, event_key, evaluation: ( + captured.update( + {"event": event, "event_key": event_key, "evaluation": evaluation} + ) + or (StoredAutomationEvent(id="event-uuid", created=True), ["action-1"]) + ), + ) + + result = processor.ingest_bank_transaction(transaction_name="ACC-BTN-0001") + + evaluation = captured["evaluation"] + proposal = getattr(evaluation, "action_proposals")[0] + assert proposal.mode is AutomationRuleMode.SUGGEST + assert proposal.disposition is AutomationActionDisposition.SUGGESTED + assert result["status"] == "ingested" + assert client.closed is True + + +def test_ingest_closes_client_after_unexpected_error(monkeypatch) -> None: + client = FakeERPNextClient(_submitted_inbound_document()) + processor = _processor(client) + monkeypatch.setattr( + payment_sync, + "upsert_bank_transaction", + lambda *_args: (_ for _ in ()).throw(RuntimeError("database unavailable")), + ) + + try: + processor.ingest_bank_transaction(transaction_name="ACC-BTN-0001") + except RuntimeError as exc: + assert str(exc) == "database unavailable" + else: # pragma: no cover - keeps the close assertion meaningful + raise AssertionError("expected ingestion failure") + + assert client.closed is True diff --git a/tests/unit/test_worker_project_payment_automation.py b/tests/unit/test_worker_project_payment_automation.py new file mode 100644 index 00000000..2d9be3a9 --- /dev/null +++ b/tests/unit/test_worker_project_payment_automation.py @@ -0,0 +1,836 @@ +"""Tests for durable execution of typed project-payment routing actions.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from types import SimpleNamespace + +import pytest + +from five08.automation_store import AutomationActionStatus, StoredAutomationAction +from five08.project_payments import ( + ProjectPaymentActionApplication, + ProjectPaymentActionApplicationStatus, + ProjectPaymentAllocationStatus, + ProjectPaymentNotification, + ProjectPaymentNotificationStatus, +) +from five08.worker import jobs as worker_jobs +from five08.worker import project_payment_automation as payment_automation + + +def _executor( + *, + automation_enabled: bool = True, + notifications_enabled: bool = False, +): + executor = payment_automation.ProjectPaymentActionExecutor.__new__( + payment_automation.ProjectPaymentActionExecutor + ) + executor.settings = SimpleNamespace( + project_payment_automation_enabled=automation_enabled, + project_payment_notifications_enabled=notifications_enabled, + api_shared_secret="secret", + discord_bot_internal_base_url="http://discord-bot.internal", + erpnext_base_url="https://erp.example.test", + erpnext_api_key="key:secret", + erpnext_api_timeout_seconds=20.0, + ) + return executor + + +def _action() -> StoredAutomationAction: + return StoredAutomationAction( + id="action-uuid", + event_id="event-uuid", + action_type="project_payment.route", + payload={"project_id": "00000000-0000-0000-0000-000000000001"}, + mode="automatic", + disposition="ready", + status=AutomationActionStatus.RUNNING, + attempts=1, + idempotency_key="action-key", + lease_token="00000000-0000-0000-0000-000000000010", + approved_by=None, + rule_project_id="00000000-0000-0000-0000-000000000001", + ) + + +def _notification() -> ProjectPaymentNotification: + return ProjectPaymentNotification( + id="notification-uuid", + allocation_id="allocation-uuid", + project_id="00000000-0000-0000-0000-000000000001", + channel_id="discord-channel", + payload={}, + status=ProjectPaymentNotificationStatus.SENDING, + attempts=1, + lease_token="00000000-0000-0000-0000-000000000020", + ) + + +def test_project_payment_action_is_disabled_without_claiming(monkeypatch) -> None: + executor = _executor(automation_enabled=False) + monkeypatch.setattr( + payment_automation, + "claim_automation_action", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("must not claim") + ), + ) + + assert executor.execute_action("action-uuid") == { + "status": "disabled", + "action_id": "action-uuid", + } + + +def test_project_payment_action_uses_atomic_application_boundary(monkeypatch) -> None: + executor = _executor(notifications_enabled=False) + captured: dict[str, object] = {} + monkeypatch.setattr( + payment_automation, + "claim_automation_action", + lambda *_args, **_kwargs: _action(), + ) + monkeypatch.setattr( + executor, "_refresh_project_from_erpnext", lambda _project_id: None + ) + monkeypatch.setattr( + executor, "_refresh_bank_transaction_from_erpnext", lambda _action_id: None + ) + monkeypatch.setattr( + payment_automation, + "apply_project_payment_automation_action", + lambda *_args, **kwargs: ( + captured.update(kwargs) + or ProjectPaymentActionApplication( + status=ProjectPaymentActionApplicationStatus.APPLIED, + action_id="action-uuid", + allocation_id="allocation-uuid", + allocation_created=True, + notification_ids=("notification-1",), + ) + ), + ) + + result = executor.execute_action("action-uuid") + + assert captured == { + "action_id": "action-uuid", + "lease_token": "00000000-0000-0000-0000-000000000010", + } + assert result == { + "status": "succeeded", + "action_id": "action-uuid", + "allocation_id": "allocation-uuid", + "allocation_created": True, + "notification_ids": ["notification-1"], + "delivered_notification_ids": [], + } + + +def test_project_payment_action_reports_atomic_policy_block(monkeypatch) -> None: + executor = _executor() + monkeypatch.setattr( + payment_automation, + "claim_automation_action", + lambda *_args, **_kwargs: _action(), + ) + monkeypatch.setattr( + executor, "_refresh_project_from_erpnext", lambda _project_id: None + ) + monkeypatch.setattr( + executor, "_refresh_bank_transaction_from_erpnext", lambda _action_id: None + ) + monkeypatch.setattr( + payment_automation, + "apply_project_payment_automation_action", + lambda *_args, **_kwargs: ProjectPaymentActionApplication( + status=ProjectPaymentActionApplicationStatus.BLOCKED, + action_id="action-uuid", + reason="payment_source_revision_superseded", + ), + ) + + assert executor.execute_action("action-uuid") == { + "status": "blocked", + "action_id": "action-uuid", + "reason": "payment_source_revision_superseded", + } + + +def test_project_payment_action_marks_unregistered_action_dead_with_its_lease( + monkeypatch, +) -> None: + executor = _executor() + action = _action() + action = action.__class__(**{**action.__dict__, "action_type": "unknown.action"}) + captured: dict[str, object] = {} + monkeypatch.setattr( + payment_automation, + "claim_automation_action", + lambda *_args, **_kwargs: action, + ) + monkeypatch.setattr( + payment_automation, + "mark_automation_action_failed", + lambda *_args, **kwargs: captured.update(kwargs) or True, + ) + + result = executor.execute_action("action-uuid") + + assert result["status"] == "ignored" + assert captured["dead"] is True + assert captured["lease_token"] == action.lease_token + + +def test_notification_delivery_posts_only_canonical_notification_id( + monkeypatch, +) -> None: + executor = _executor(notifications_enabled=True) + delivered: dict[str, object] = {} + monkeypatch.setattr( + payment_automation, + "block_project_payment_notification_if_ineligible", + lambda *_args, **_kwargs: False, + ) + monkeypatch.setattr( + payment_automation, + "claim_project_payment_notification", + lambda *_args, **_kwargs: _notification(), + ) + monkeypatch.setattr( + executor, + "_notification_source_block_reason", + lambda **_kwargs: None, + ) + monkeypatch.setattr( + executor, + "_refresh_project_from_erpnext", + lambda _project_id: None, + ) + + class _Client: + status_code = None + + def __init__(self, *_args, **_kwargs) -> None: + return None + + def post_project_payment_notification(self, **kwargs): + delivered["request"] = kwargs + return {"message_id": "discord-message"} + + monkeypatch.setattr(payment_automation, "DiscordBotClient", _Client) + monkeypatch.setattr( + payment_automation, + "mark_project_payment_notification_sent", + lambda *_args, **kwargs: delivered.update(kwargs) or True, + ) + + result = executor.deliver_notification("notification-uuid") + + assert result["status"] == "sent" + assert delivered["request"] == { + "notification_id": "notification-uuid", + "lease_token": "00000000-0000-0000-0000-000000000020", + } + assert delivered["lease_token"] == "00000000-0000-0000-0000-000000000020" + + +def test_notification_delivery_blocks_bot_policy_rejection_with_worker_lease( + monkeypatch, +) -> None: + executor = _executor(notifications_enabled=True) + failed: dict[str, object] = {} + monkeypatch.setattr( + payment_automation, + "block_project_payment_notification_if_ineligible", + lambda *_args, **_kwargs: False, + ) + monkeypatch.setattr( + payment_automation, + "claim_project_payment_notification", + lambda *_args, **_kwargs: _notification(), + ) + monkeypatch.setattr( + executor, + "_notification_source_block_reason", + lambda **_kwargs: None, + ) + monkeypatch.setattr( + executor, + "_refresh_project_from_erpnext", + lambda _project_id: None, + ) + + class _Client: + status_code = 409 + + def __init__(self, *_args, **_kwargs) -> None: + return None + + def post_project_payment_notification(self, **_kwargs): + raise payment_automation.DiscordBotAPIError("mapping revoked") + + monkeypatch.setattr(payment_automation, "DiscordBotClient", _Client) + monkeypatch.setattr( + payment_automation, + "mark_project_payment_notification_failed", + lambda *_args, **kwargs: failed.update(kwargs) or True, + ) + + result = executor.deliver_notification("notification-uuid") + + assert result["status"] == "blocked" + assert failed["blocked"] is True + assert failed["lease_token"] == "00000000-0000-0000-0000-000000000020" + + +def test_notification_delivery_blocks_a_superseded_source_before_discord( + monkeypatch, +) -> None: + executor = _executor(notifications_enabled=True) + failed: dict[str, object] = {} + monkeypatch.setattr( + payment_automation, + "block_project_payment_notification_if_ineligible", + lambda *_args, **_kwargs: False, + ) + monkeypatch.setattr( + payment_automation, + "claim_project_payment_notification", + lambda *_args, **_kwargs: _notification(), + ) + monkeypatch.setattr( + executor, + "_notification_source_block_reason", + lambda **_kwargs: "payment_source_revision_superseded", + ) + monkeypatch.setattr( + executor, + "_refresh_project_from_erpnext", + lambda _project_id: None, + ) + monkeypatch.setattr( + payment_automation, + "mark_project_payment_notification_failed", + lambda *_args, **kwargs: failed.update(kwargs) or True, + ) + monkeypatch.setattr( + payment_automation, + "DiscordBotClient", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("must not send a superseded payment") + ), + ) + + result = executor.deliver_notification("notification-uuid") + + assert result == { + "status": "blocked", + "notification_id": "notification-uuid", + "reason": "payment_source_revision_superseded", + } + assert failed["blocked"] is True + assert failed["lease_token"] == "00000000-0000-0000-0000-000000000020" + + +def test_notification_delivery_blocks_a_closed_erp_project_before_discord( + monkeypatch, +) -> None: + executor = _executor(notifications_enabled=True) + failed: dict[str, object] = {} + monkeypatch.setattr( + payment_automation, + "block_project_payment_notification_if_ineligible", + lambda *_args, **_kwargs: False, + ) + monkeypatch.setattr( + payment_automation, + "claim_project_payment_notification", + lambda *_args, **_kwargs: _notification(), + ) + monkeypatch.setattr( + executor, + "_refresh_project_from_erpnext", + lambda _project_id: (_ for _ in ()).throw( + payment_automation.ProjectPaymentActionPreflightBlocked( + "payment_project_is_not_open_in_erpnext" + ) + ), + ) + monkeypatch.setattr( + payment_automation, + "mark_project_payment_notification_failed", + lambda *_args, **kwargs: failed.update(kwargs) or True, + ) + monkeypatch.setattr( + executor, + "_notification_source_block_reason", + lambda **_kwargs: (_ for _ in ()).throw( + AssertionError("must not check a closed project payment") + ), + ) + + result = executor.deliver_notification("notification-uuid") + + assert result == { + "status": "blocked", + "notification_id": "notification-uuid", + "reason": "payment_project_is_not_open_in_erpnext", + } + assert failed["blocked"] is True + assert failed["lease_token"] == "00000000-0000-0000-0000-000000000020" + + +def test_notification_delivery_retries_when_bot_configuration_is_repaired( + monkeypatch, +) -> None: + executor = _executor(notifications_enabled=True) + executor.settings.discord_bot_internal_base_url = "" + failed: dict[str, object] = {} + monkeypatch.setattr( + payment_automation, + "block_project_payment_notification_if_ineligible", + lambda *_args, **_kwargs: False, + ) + monkeypatch.setattr( + payment_automation, + "claim_project_payment_notification", + lambda *_args, **_kwargs: _notification(), + ) + monkeypatch.setattr( + payment_automation, + "mark_project_payment_notification_failed", + lambda *_args, **kwargs: failed.update(kwargs) or True, + ) + + result = executor.deliver_notification("notification-uuid") + + assert result == { + "status": "failed", + "notification_id": "notification-uuid", + "reason": "discord_bot_not_configured", + } + assert "blocked" not in failed + assert failed["lease_token"] == "00000000-0000-0000-0000-000000000020" + + +def test_payment_action_refreshes_the_current_erp_project_before_allocation( + monkeypatch, +) -> None: + executor = _executor() + captured: dict[str, object] = {} + monkeypatch.setattr( + payment_automation, + "erpnext_project_external_id", + lambda *_args, **_kwargs: "PROJ-001", + ) + + class _ERPClient: + def __init__(self, base_url, api_key, *, timeout_seconds) -> None: + captured.update( + { + "base_url": base_url, + "api_key": api_key, + "timeout_seconds": timeout_seconds, + } + ) + + def get_project(self, external_id: str) -> dict[str, str]: + captured["external_id"] = external_id + return {"name": external_id, "project_name": "Current", "status": "Open"} + + def close(self) -> None: + captured["closed"] = True + + payload = SimpleNamespace( + external_id="PROJ-001", + source_status="Open", + source_modified_at=datetime(2026, 7, 16, tzinfo=timezone.utc), + ) + monkeypatch.setattr(payment_automation, "ERPNextClient", _ERPClient) + monkeypatch.setattr( + payment_automation, "erpnext_project_to_input", lambda document: payload + ) + monkeypatch.setattr( + payment_automation, + "upsert_project", + lambda _settings, received_payload: ( + captured.update({"payload": received_payload}) + or "00000000-0000-0000-0000-000000000001" + ), + ) + + executor._refresh_project_from_erpnext("00000000-0000-0000-0000-000000000001") + + assert captured["external_id"] == "PROJ-001" + assert captured["payload"] is payload + assert captured["closed"] is True + + +@pytest.mark.parametrize( + ("source_status", "source_modified_at", "reason"), + [ + ( + "Completed", + datetime(2026, 7, 16, tzinfo=timezone.utc), + "payment_project_is_not_open_in_erpnext", + ), + ("Open", None, "payment_project_has_no_canonical_revision"), + ], +) +def test_payment_action_fails_closed_for_noncurrent_erp_project( + monkeypatch, + source_status: str, + source_modified_at: datetime | None, + reason: str, +) -> None: + executor = _executor() + monkeypatch.setattr( + payment_automation, + "erpnext_project_external_id", + lambda *_args, **_kwargs: "PROJ-001", + ) + + class _ERPClient: + def get_project(self, _external_id: str) -> dict[str, object]: + return {"name": "PROJ-001"} + + def close(self) -> None: + return None + + monkeypatch.setattr(executor, "_erpnext_client", lambda: _ERPClient()) + monkeypatch.setattr( + payment_automation, + "erpnext_project_to_input", + lambda _document: SimpleNamespace( + external_id="PROJ-001", + source_status=source_status, + source_modified_at=source_modified_at, + ), + ) + monkeypatch.setattr( + payment_automation, + "upsert_project", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("must not cache a noncurrent project") + ), + ) + + with pytest.raises(payment_automation.ProjectPaymentActionPreflightBlocked) as exc: + executor._refresh_project_from_erpnext("00000000-0000-0000-0000-000000000001") + + assert str(exc.value) == reason + + +def test_payment_action_rechecks_canonical_bank_transaction_before_allocation( + monkeypatch, +) -> None: + executor = _executor() + captured: dict[str, object] = {} + monkeypatch.setattr( + payment_automation, + "automation_action_subject_id", + lambda *_args, **_kwargs: "transaction-uuid", + ) + monkeypatch.setattr( + payment_automation, + "get_bank_transaction", + lambda *_args, **_kwargs: SimpleNamespace( + id="transaction-uuid", + source="erpnext", + external_id="ACC-BTN-0001", + source_revision="2026-07-16T12:34:56+00:00", + ), + ) + + class _ERPClient: + def __init__(self, *_args, **_kwargs) -> None: + return None + + def get_bank_transaction(self, external_id: str) -> dict[str, object]: + captured["external_id"] = external_id + return { + "name": external_id, + "docstatus": 1, + "deposit": "1250.00", + "withdrawal": "0", + "modified": "2026-07-16T12:34:56+00:00", + } + + def close(self) -> None: + captured["closed"] = True + + normalized = SimpleNamespace( + external_id="ACC-BTN-0001", + source_revision="2026-07-16T12:34:56+00:00", + source_modified_at=datetime(2026, 7, 16, 12, 34, 56, tzinfo=timezone.utc), + ) + monkeypatch.setattr(payment_automation, "ERPNextClient", _ERPClient) + monkeypatch.setattr( + payment_automation, + "erpnext_bank_transaction_to_input", + lambda document: normalized, + ) + monkeypatch.setattr( + payment_automation, + "upsert_bank_transaction", + lambda _settings, transaction: ( + captured.update({"transaction": transaction}) + or SimpleNamespace(id="transaction-uuid", accepted=True) + ), + ) + + executor._refresh_bank_transaction_from_erpnext("action-uuid") + + assert captured == { + "external_id": "ACC-BTN-0001", + "closed": True, + "transaction": normalized, + } + + +@pytest.mark.parametrize( + ("canonical_revision", "source_modified_at", "upsert_accepted", "reason"), + [ + (None, None, True, "bank_transaction_has_no_canonical_revision"), + ( + "not-an-erpnext-timestamp", + None, + True, + "bank_transaction_has_no_canonical_revision", + ), + ( + "2026-07-16T12:34:56+00:00", + datetime(2026, 7, 16, 12, 34, 56, tzinfo=timezone.utc), + False, + "bank_transaction_revision_refresh_was_not_accepted", + ), + ], +) +def test_payment_action_fails_closed_when_canonical_revision_cannot_be_persisted( + monkeypatch, + canonical_revision: str | None, + source_modified_at: datetime | None, + upsert_accepted: bool, + reason: str, +) -> None: + executor = _executor() + monkeypatch.setattr( + payment_automation, + "automation_action_subject_id", + lambda *_args, **_kwargs: "transaction-uuid", + ) + monkeypatch.setattr( + payment_automation, + "get_bank_transaction", + lambda *_args, **_kwargs: SimpleNamespace( + id="transaction-uuid", + source="erpnext", + external_id="ACC-BTN-0001", + source_revision="2026-07-16T12:34:56+00:00", + ), + ) + + class _ERPClient: + def get_bank_transaction(self, _external_id: str) -> dict[str, object]: + return {"name": "ACC-BTN-0001", "docstatus": 1} + + def close(self) -> None: + return None + + monkeypatch.setattr(executor, "_erpnext_client", lambda: _ERPClient()) + monkeypatch.setattr( + payment_automation, + "erpnext_bank_transaction_to_input", + lambda _document: SimpleNamespace( + external_id="ACC-BTN-0001", + source_revision=canonical_revision, + source_modified_at=source_modified_at, + ), + ) + monkeypatch.setattr( + payment_automation, + "upsert_bank_transaction", + lambda *_args, **_kwargs: SimpleNamespace( + id="transaction-uuid", + accepted=upsert_accepted, + ), + ) + + with pytest.raises(payment_automation.ProjectPaymentActionPreflightBlocked) as exc: + executor._refresh_bank_transaction_from_erpnext("action-uuid") + + assert str(exc.value) == reason + + +def test_notification_source_recheck_blocks_a_cancelled_canonical_document( + monkeypatch, +) -> None: + executor = _executor(notifications_enabled=True) + captured: dict[str, object] = {} + source_context = SimpleNamespace( + notification_id="notification-uuid", + bank_transaction_id="transaction-uuid", + source="erpnext", + external_id="ACC-BTN-0001", + allocation_status=ProjectPaymentAllocationStatus.CONFIRMED, + allocation_source_revision="2026-07-16T12:34:56+00:00", + current_source_revision="2026-07-16T12:34:56+00:00", + ) + monkeypatch.setattr( + payment_automation, + "get_project_payment_notification_source_context", + lambda *_args, **kwargs: captured.update(kwargs) or source_context, + ) + + class _ERPClient: + def get_bank_transaction(self, external_id: str) -> dict[str, object]: + captured["external_id"] = external_id + return {"name": external_id, "docstatus": 2} + + def close(self) -> None: + captured["closed"] = True + + monkeypatch.setattr(executor, "_erpnext_client", lambda: _ERPClient()) + monkeypatch.setattr( + payment_automation, + "erpnext_bank_transaction_to_input", + lambda _document: None, + ) + + result = executor._notification_source_block_reason( + notification_id="notification-uuid", + lease_token="00000000-0000-0000-0000-000000000020", + ) + + assert result == "bank_transaction_is_no_longer_submitted" + assert captured == { + "notification_id": "notification-uuid", + "lease_token": "00000000-0000-0000-0000-000000000020", + "external_id": "ACC-BTN-0001", + "closed": True, + } + + +def test_payment_action_blocks_when_canonical_bank_transaction_is_cancelled( + monkeypatch, +) -> None: + executor = _executor() + action = _action() + failed: dict[str, object] = {} + monkeypatch.setattr( + payment_automation, + "claim_automation_action", + lambda *_args, **_kwargs: action, + ) + monkeypatch.setattr( + executor, + "_refresh_bank_transaction_from_erpnext", + lambda _action_id: (_ for _ in ()).throw( + payment_automation.ProjectPaymentActionPreflightBlocked( + "bank_transaction_is_no_longer_submitted" + ) + ), + ) + monkeypatch.setattr( + payment_automation, + "mark_automation_action_failed", + lambda *_args, **kwargs: failed.update(kwargs) or True, + ) + monkeypatch.setattr( + payment_automation, + "apply_project_payment_automation_action", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("must not allocate a canceled transaction") + ), + ) + + result = executor.execute_action("action-uuid") + + assert result == { + "status": "blocked", + "action_id": "action-uuid", + "reason": "bank_transaction_is_no_longer_submitted", + } + assert failed["dead"] is True + assert failed["lease_token"] == action.lease_token + + +def test_payment_recovery_does_nothing_when_automation_is_disabled(monkeypatch) -> None: + monkeypatch.setattr( + worker_jobs.settings, "project_payment_automation_enabled", False + ) + monkeypatch.setattr( + worker_jobs, + "list_pending_automation_action_ids", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("must not list") + ), + ) + + assert worker_jobs.recover_project_payment_automation_job() == { + "status": "disabled" + } + + +def test_payment_recovery_executes_actions_and_retries_notifications_when_enabled( + monkeypatch, +) -> None: + calls: list[tuple[str, str]] = [] + + class _RecoveryExecutor: + def execute_action(self, action_id: str) -> dict[str, str]: + calls.append(("action", action_id)) + return {"status": "succeeded", "action_id": action_id} + + def deliver_notification(self, notification_id: str) -> dict[str, str]: + calls.append(("notification", notification_id)) + return {"status": "sent", "notification_id": notification_id} + + monkeypatch.setattr( + worker_jobs.settings, "project_payment_automation_enabled", True + ) + monkeypatch.setattr( + worker_jobs.settings, "project_payment_notifications_enabled", True + ) + monkeypatch.setattr( + worker_jobs, + "ProjectPaymentActionExecutor", + lambda: _RecoveryExecutor(), + ) + monkeypatch.setattr( + worker_jobs, + "list_pending_automation_action_ids", + lambda *_args, **_kwargs: ["stale-action"], + ) + monkeypatch.setattr( + worker_jobs, + "list_retryable_project_payment_notification_ids", + lambda *_args, **_kwargs: ["stale-notification"], + ) + monkeypatch.setattr( + worker_jobs, + "recover_project_payment_learning", + lambda *_args, **_kwargs: { + "attempted": 1, + "created": 1, + "existing": 0, + "ineligible": 0, + "failed": 0, + }, + ) + + result = worker_jobs.recover_project_payment_automation_job() + + assert result["status"] == "recovered" + assert calls == [("action", "stale-action"), ("notification", "stale-notification")] + assert result["learning"] == { + "attempted": 1, + "created": 1, + "existing": 0, + "ineligible": 0, + "failed": 0, + } diff --git a/tests/unit/test_worker_project_payment_learning.py b/tests/unit/test_worker_project_payment_learning.py new file mode 100644 index 00000000..6e69a791 --- /dev/null +++ b/tests/unit/test_worker_project_payment_learning.py @@ -0,0 +1,178 @@ +"""Tests for durable recovery of feedback-derived payment suggestions.""" + +from __future__ import annotations + +from five08.automation_store import ( + AutomationActionStatus, + StoredAutomationAction, + StoredAutomationReviewAction, + StoredAutomationRule, +) +from five08.worker import project_payment_learning as payment_learning + + +PROJECT_ID = "00000000-0000-0000-0000-000000000001" +ACTION_ID = "00000000-0000-0000-0000-000000000002" + + +def _review( + *, snapshot: dict[str, object] | None = None +) -> StoredAutomationReviewAction: + return StoredAutomationReviewAction( + action=StoredAutomationAction( + id=ACTION_ID, + event_id="00000000-0000-0000-0000-000000000003", + action_type="project_payment.route", + payload={"project_id": PROJECT_ID}, + mode="suggest", + disposition="suggested", + status=AutomationActionStatus.APPROVED, + attempts=0, + idempotency_key="action-key", + lease_token=None, + approved_by="discord:admin-1", + rule_project_id=PROJECT_ID, + ), + subject_id="00000000-0000-0000-0000-000000000004", + subject_snapshot=snapshot + or { + "direction": "inbound", + "currency": "GBP", + "counterparty": "Acme Ltd", + }, + ) + + +def test_recovery_derives_a_missing_suggestion_rule_once(monkeypatch) -> None: + review = _review() + rule = payment_learning.learned_project_payment_suggestion_rule( + project_id=PROJECT_ID, + subject_snapshot=review.subject_snapshot, + ) + assert rule is not None + monkeypatch.setattr( + payment_learning, + "list_approved_automation_review_actions", + lambda *_args, **_kwargs: [review], + ) + monkeypatch.setattr( + payment_learning, "project_is_open", lambda *_args, **_kwargs: True + ) + captured: dict[str, object] = {} + + def create(*args, **kwargs): # noqa: ANN001, ANN202 + captured["args"] = args + captured["kwargs"] = kwargs + return StoredAutomationRule(rule=rule, created_by="system"), True + + monkeypatch.setattr(payment_learning, "create_automation_rule_if_absent", create) + + result = payment_learning.recover_project_payment_learning( + payment_learning.SharedSettings(), + ) + + assert result == { + "attempted": 1, + "created": 1, + "existing": 0, + "ineligible": 0, + "failed": 0, + } + assert captured["kwargs"] == { + "rule": rule, + "created_by": f"system:project-payment-learning:{ACTION_ID}", + } + + +def test_recovery_skips_feedback_without_safe_identity_evidence(monkeypatch) -> None: + monkeypatch.setattr( + payment_learning, + "list_approved_automation_review_actions", + lambda *_args, **_kwargs: [ + _review(snapshot={"direction": "inbound", "currency": "GBP"}) + ], + ) + monkeypatch.setattr( + payment_learning, "project_is_open", lambda *_args, **_kwargs: True + ) + monkeypatch.setattr( + payment_learning, + "create_automation_rule_if_absent", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("must not create") + ), + ) + + result = payment_learning.recover_project_payment_learning( + payment_learning.SharedSettings(), + ) + + assert result == { + "attempted": 1, + "created": 0, + "existing": 0, + "ineligible": 1, + "failed": 0, + } + + +def test_recovery_skips_closed_project_feedback(monkeypatch) -> None: + monkeypatch.setattr( + payment_learning, + "list_approved_automation_review_actions", + lambda *_args, **_kwargs: [_review()], + ) + monkeypatch.setattr( + payment_learning, "project_is_open", lambda *_args, **_kwargs: False + ) + monkeypatch.setattr( + payment_learning, + "create_automation_rule_if_absent", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("must not create") + ), + ) + + result = payment_learning.recover_project_payment_learning( + payment_learning.SharedSettings(), + ) + + assert result == { + "attempted": 1, + "created": 0, + "existing": 0, + "ineligible": 1, + "failed": 0, + } + + +def test_recovery_records_one_failure_without_aborting_other_feedback( + monkeypatch, +) -> None: + monkeypatch.setattr( + payment_learning, + "list_approved_automation_review_actions", + lambda *_args, **_kwargs: [_review()], + ) + monkeypatch.setattr( + payment_learning, "project_is_open", lambda *_args, **_kwargs: True + ) + monkeypatch.setattr( + payment_learning, + "create_automation_rule_if_absent", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + RuntimeError("temporary db failure") + ), + ) + + result = payment_learning.recover_project_payment_learning( + payment_learning.SharedSettings(), + ) + + assert result == { + "attempted": 1, + "created": 0, + "existing": 0, + "ineligible": 0, + "failed": 1, + }