Compare commits
3 Commits
77a11ef32e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3401af3952 | ||
|
|
19c4841afc | ||
|
|
1ce4162e4a |
112
backend/app/agents/analyst/__init__.py
Normal file
112
backend/app/agents/analyst/__init__.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Iterable, List
|
||||||
|
|
||||||
|
from app.agents.form_auditor.models import ExtractedIrsForm990PfDataSchema
|
||||||
|
|
||||||
|
from .agent import agent
|
||||||
|
from .metrics import SnapshotBundle, build_key_metrics, build_snapshots
|
||||||
|
from .models import AnalystReport, AnalystState
|
||||||
|
|
||||||
|
__all__ = ["build_performance_report"]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_year(
|
||||||
|
entry: dict[str, Any], extraction: ExtractedIrsForm990PfDataSchema
|
||||||
|
) -> int:
|
||||||
|
candidates: Iterable[Any] = (
|
||||||
|
entry.get("calendar_year"),
|
||||||
|
entry.get("year"),
|
||||||
|
entry.get("tax_year"),
|
||||||
|
entry.get("return_year"),
|
||||||
|
entry.get("metadata", {}).get("return_year")
|
||||||
|
if isinstance(entry.get("metadata"), dict)
|
||||||
|
else None,
|
||||||
|
entry.get("metadata", {}).get("tax_year")
|
||||||
|
if isinstance(entry.get("metadata"), dict)
|
||||||
|
else None,
|
||||||
|
extraction.core_organization_metadata.calendar_year,
|
||||||
|
)
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate in (None, ""):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return int(candidate)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
raise ValueError("Unable to determine filing year for one of the payload entries.")
|
||||||
|
|
||||||
|
|
||||||
|
async def build_performance_report(payloads: List[dict[str, Any]]) -> AnalystReport:
|
||||||
|
if not payloads:
|
||||||
|
raise ValueError("At least one payload is required for performance analysis.")
|
||||||
|
|
||||||
|
bundles: List[SnapshotBundle] = []
|
||||||
|
|
||||||
|
organisation_name = ""
|
||||||
|
organisation_ein = ""
|
||||||
|
|
||||||
|
for entry in payloads:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise TypeError("Each payload entry must be a dict.")
|
||||||
|
|
||||||
|
extraction_payload = entry.get("extraction") if "extraction" in entry else entry
|
||||||
|
extraction = ExtractedIrsForm990PfDataSchema.model_validate(extraction_payload)
|
||||||
|
year = _resolve_year(entry, extraction)
|
||||||
|
|
||||||
|
if not organisation_ein:
|
||||||
|
organisation_ein = extraction.core_organization_metadata.ein
|
||||||
|
organisation_name = extraction.core_organization_metadata.legal_name
|
||||||
|
else:
|
||||||
|
if extraction.core_organization_metadata.ein != organisation_ein:
|
||||||
|
raise ValueError(
|
||||||
|
"All payload entries must belong to the same organization."
|
||||||
|
)
|
||||||
|
|
||||||
|
bundles.append(SnapshotBundle(year=year, extraction=extraction))
|
||||||
|
|
||||||
|
bundles.sort(key=lambda bundle: bundle.year)
|
||||||
|
snapshots = build_snapshots(bundles)
|
||||||
|
metrics = build_key_metrics(snapshots)
|
||||||
|
|
||||||
|
notes = []
|
||||||
|
if metrics:
|
||||||
|
revenue_metric = metrics[0]
|
||||||
|
expense_metric = metrics[1] if len(metrics) > 1 else None
|
||||||
|
if revenue_metric.cagr is not None:
|
||||||
|
notes.append(f"Revenue CAGR: {revenue_metric.cagr:.2%}")
|
||||||
|
if expense_metric and expense_metric.cagr is not None:
|
||||||
|
notes.append(f"Expense CAGR: {expense_metric.cagr:.2%}")
|
||||||
|
surplus_metric = next(
|
||||||
|
(m for m in metrics if m.name == "Operating Surplus"), None
|
||||||
|
)
|
||||||
|
if surplus_metric:
|
||||||
|
last_value = surplus_metric.points[-1].value if surplus_metric.points else 0
|
||||||
|
notes.append(f"Latest operating surplus: {last_value:,.0f}")
|
||||||
|
|
||||||
|
state = AnalystState(
|
||||||
|
organisation_name=organisation_name,
|
||||||
|
organisation_ein=organisation_ein,
|
||||||
|
series=snapshots,
|
||||||
|
key_metrics=metrics,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
"Analyze the provided multi-year financial context. Quantify notable trends, "
|
||||||
|
"call out risks or strengths, and supply actionable recommendations. "
|
||||||
|
"Capture both positive momentum and areas requiring attention."
|
||||||
|
)
|
||||||
|
result = await agent.run(prompt, deps=state)
|
||||||
|
report = result.output
|
||||||
|
|
||||||
|
years = [snapshot.year for snapshot in snapshots]
|
||||||
|
|
||||||
|
return report.model_copy(
|
||||||
|
update={
|
||||||
|
"organisation_name": organisation_name,
|
||||||
|
"organisation_ein": organisation_ein,
|
||||||
|
"years_analyzed": years,
|
||||||
|
"key_metrics": metrics,
|
||||||
|
}
|
||||||
|
)
|
||||||
33
backend/app/agents/analyst/agent.py
Normal file
33
backend/app/agents/analyst/agent.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic_ai import Agent
|
||||||
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
|
from pydantic_ai.providers.azure import AzureProvider
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
from .models import AnalystReport, AnalystState
|
||||||
|
|
||||||
|
provider = AzureProvider(
|
||||||
|
azure_endpoint=settings.AZURE_OPENAI_ENDPOINT,
|
||||||
|
api_version=settings.AZURE_OPENAI_API_VERSION,
|
||||||
|
api_key=settings.AZURE_OPENAI_API_KEY,
|
||||||
|
)
|
||||||
|
|
||||||
|
model = OpenAIChatModel(model_name="gpt-4o", provider=provider)
|
||||||
|
|
||||||
|
agent = Agent(
|
||||||
|
model=model,
|
||||||
|
name="MultiYearAnalyst",
|
||||||
|
deps_type=AnalystState,
|
||||||
|
output_type=AnalystReport,
|
||||||
|
system_prompt=(
|
||||||
|
"You are a nonprofit financial analyst. You receive multi-year Form 990 extractions "
|
||||||
|
"summarized into deterministic metrics (series, ratios, surplus, CAGR). Use the context "
|
||||||
|
"to highlight performance trends, governance implications, and forward-looking risks. "
|
||||||
|
"Focus on numeric trends: revenue growth, expense discipline, surplus stability, "
|
||||||
|
"program-vs-admin mix, and fundraising efficiency. Provide concise bullet insights, "
|
||||||
|
"clear recommendations tied to the data, and a balanced outlook (strengths vs watch items). "
|
||||||
|
"Only cite facts available in the provided series—do not invent figures."
|
||||||
|
),
|
||||||
|
)
|
||||||
197
backend/app/agents/analyst/metrics.py
Normal file
197
backend/app/agents/analyst/metrics.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable, List, Sequence, Tuple
|
||||||
|
|
||||||
|
from app.agents.form_auditor.models import ExtractedIrsForm990PfDataSchema
|
||||||
|
|
||||||
|
from .models import TrendDirection, TrendMetric, TrendMetricPoint, YearlySnapshot
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SnapshotBundle:
|
||||||
|
year: int
|
||||||
|
extraction: ExtractedIrsForm990PfDataSchema
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_ratio(numerator: float, denominator: float) -> float | None:
|
||||||
|
if denominator in (0, None):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return numerator / denominator
|
||||||
|
except ZeroDivisionError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _growth(current: float, previous: float | None) -> float | None:
|
||||||
|
if previous in (None, 0):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return (current - previous) / previous
|
||||||
|
except ZeroDivisionError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _direction_from_points(values: Sequence[float | None]) -> TrendDirection:
|
||||||
|
clean = [value for value in values if value is not None]
|
||||||
|
if len(clean) < 2:
|
||||||
|
return TrendDirection.STABLE
|
||||||
|
|
||||||
|
start, end = clean[0], clean[-1]
|
||||||
|
if start is None or end is None:
|
||||||
|
return TrendDirection.STABLE
|
||||||
|
|
||||||
|
delta = end - start
|
||||||
|
tolerance = abs(start) * 0.02 if start else 0.01
|
||||||
|
if abs(delta) <= tolerance:
|
||||||
|
return TrendDirection.STABLE
|
||||||
|
|
||||||
|
if len(clean) > 2:
|
||||||
|
swings = sum(
|
||||||
|
1
|
||||||
|
for idx in range(1, len(clean) - 1)
|
||||||
|
if (clean[idx] - clean[idx - 1]) * (clean[idx + 1] - clean[idx]) < 0
|
||||||
|
)
|
||||||
|
if swings >= len(clean) // 2:
|
||||||
|
return TrendDirection.VOLATILE
|
||||||
|
|
||||||
|
return TrendDirection.IMPROVING if delta > 0 else TrendDirection.DECLINING
|
||||||
|
|
||||||
|
|
||||||
|
def _cagr(start: float | None, end: float | None, periods: int) -> float | None:
|
||||||
|
if start is None or end is None or start <= 0 or end <= 0 or periods <= 0:
|
||||||
|
return None
|
||||||
|
return (end / start) ** (1 / periods) - 1
|
||||||
|
|
||||||
|
|
||||||
|
def build_snapshots(bundles: Sequence[SnapshotBundle]) -> List[YearlySnapshot]:
|
||||||
|
snapshots: List[YearlySnapshot] = []
|
||||||
|
previous_revenue = None
|
||||||
|
previous_expenses = None
|
||||||
|
|
||||||
|
for bundle in bundles:
|
||||||
|
rev = bundle.extraction.revenue_breakdown.total_revenue
|
||||||
|
exp = bundle.extraction.expenses_breakdown.total_expenses
|
||||||
|
program = bundle.extraction.expenses_breakdown.program_services_expenses
|
||||||
|
admin = bundle.extraction.expenses_breakdown.management_general_expenses
|
||||||
|
fundraising = bundle.extraction.expenses_breakdown.fundraising_expenses
|
||||||
|
|
||||||
|
snapshots.append(
|
||||||
|
YearlySnapshot(
|
||||||
|
year=bundle.year,
|
||||||
|
total_revenue=rev,
|
||||||
|
total_expenses=exp,
|
||||||
|
revenue_growth=_growth(rev, previous_revenue),
|
||||||
|
expense_growth=_growth(exp, previous_expenses),
|
||||||
|
surplus=rev - exp,
|
||||||
|
program_ratio=_safe_ratio(program, exp),
|
||||||
|
admin_ratio=_safe_ratio(admin, exp),
|
||||||
|
fundraising_ratio=_safe_ratio(fundraising, exp),
|
||||||
|
net_margin=_safe_ratio(rev - exp, rev),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
previous_revenue = rev
|
||||||
|
previous_expenses = exp
|
||||||
|
|
||||||
|
return snapshots
|
||||||
|
|
||||||
|
|
||||||
|
def _metric_from_series(
|
||||||
|
name: str,
|
||||||
|
unit: str,
|
||||||
|
description: str,
|
||||||
|
values: Iterable[Tuple[int, float | None]],
|
||||||
|
) -> TrendMetric:
|
||||||
|
points = [
|
||||||
|
TrendMetricPoint(year=year, value=value or 0.0, growth=None)
|
||||||
|
for year, value in values
|
||||||
|
]
|
||||||
|
|
||||||
|
for idx in range(1, len(points)):
|
||||||
|
prev = points[idx - 1].value
|
||||||
|
curr = points[idx].value
|
||||||
|
points[idx].growth = _growth(curr, prev)
|
||||||
|
|
||||||
|
data_values = [point.value for point in points]
|
||||||
|
direction = _direction_from_points(data_values)
|
||||||
|
cagr = None
|
||||||
|
if len(points) >= 2:
|
||||||
|
cagr = _cagr(points[0].value, points[-1].value, len(points) - 1)
|
||||||
|
|
||||||
|
return TrendMetric(
|
||||||
|
name=name,
|
||||||
|
unit=unit,
|
||||||
|
description=description,
|
||||||
|
points=points,
|
||||||
|
cagr=cagr,
|
||||||
|
direction=direction,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_key_metrics(snapshots: Sequence[YearlySnapshot]) -> List[TrendMetric]:
|
||||||
|
if not snapshots:
|
||||||
|
return []
|
||||||
|
|
||||||
|
metrics = [
|
||||||
|
_metric_from_series(
|
||||||
|
"Total Revenue",
|
||||||
|
"USD",
|
||||||
|
"Reported total revenue in Part I.",
|
||||||
|
[(snap.year, snap.total_revenue) for snap in snapshots],
|
||||||
|
),
|
||||||
|
_metric_from_series(
|
||||||
|
"Total Expenses",
|
||||||
|
"USD",
|
||||||
|
"Reported total expenses in Part I.",
|
||||||
|
[(snap.year, snap.total_expenses) for snap in snapshots],
|
||||||
|
),
|
||||||
|
_metric_from_series(
|
||||||
|
"Operating Surplus",
|
||||||
|
"USD",
|
||||||
|
"Difference between total revenue and total expenses.",
|
||||||
|
[(snap.year, snap.surplus) for snap in snapshots],
|
||||||
|
),
|
||||||
|
_metric_from_series(
|
||||||
|
"Program Service Ratio",
|
||||||
|
"Ratio",
|
||||||
|
"Program service expenses divided by total expenses.",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
snap.year,
|
||||||
|
snap.program_ratio if snap.program_ratio is not None else 0.0,
|
||||||
|
)
|
||||||
|
for snap in snapshots
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_metric_from_series(
|
||||||
|
"Administrative Ratio",
|
||||||
|
"Ratio",
|
||||||
|
"Management & general expenses divided by total expenses.",
|
||||||
|
[
|
||||||
|
(snap.year, snap.admin_ratio if snap.admin_ratio is not None else 0.0)
|
||||||
|
for snap in snapshots
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_metric_from_series(
|
||||||
|
"Fundraising Ratio",
|
||||||
|
"Ratio",
|
||||||
|
"Fundraising expenses divided by total expenses.",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
snap.year,
|
||||||
|
snap.fundraising_ratio
|
||||||
|
if snap.fundraising_ratio is not None
|
||||||
|
else 0.0,
|
||||||
|
)
|
||||||
|
for snap in snapshots
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for metric in metrics:
|
||||||
|
if metric.name.endswith("Ratio"):
|
||||||
|
metric.notes = "Higher values indicate greater spending share."
|
||||||
|
elif metric.name == "Operating Surplus":
|
||||||
|
metric.notes = "Positive surplus implies revenues exceeded expenses."
|
||||||
|
|
||||||
|
return metrics
|
||||||
74
backend/app/agents/analyst/models.py
Normal file
74
backend/app/agents/analyst/models.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class TrendDirection(str, Enum):
|
||||||
|
IMPROVING = "Improving"
|
||||||
|
DECLINING = "Declining"
|
||||||
|
STABLE = "Stable"
|
||||||
|
VOLATILE = "Volatile"
|
||||||
|
|
||||||
|
|
||||||
|
class TrendMetricPoint(BaseModel):
|
||||||
|
year: int
|
||||||
|
value: float
|
||||||
|
growth: float | None = Field(
|
||||||
|
default=None, description="Year-over-year growth expressed as a decimal."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TrendMetric(BaseModel):
|
||||||
|
name: str
|
||||||
|
unit: str
|
||||||
|
description: str
|
||||||
|
points: List[TrendMetricPoint]
|
||||||
|
cagr: float | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Compound annual growth rate across the analyzed period.",
|
||||||
|
)
|
||||||
|
direction: TrendDirection = Field(
|
||||||
|
default=TrendDirection.STABLE, description="Overall direction of the metric."
|
||||||
|
)
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TrendInsight(BaseModel):
|
||||||
|
category: str
|
||||||
|
direction: TrendDirection
|
||||||
|
summary: str
|
||||||
|
confidence: float = Field(default=0.7, ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class AnalystReport(BaseModel):
|
||||||
|
organisation_name: str
|
||||||
|
organisation_ein: str
|
||||||
|
years_analyzed: List[int] = Field(default_factory=list)
|
||||||
|
key_metrics: List[TrendMetric] = Field(default_factory=list)
|
||||||
|
insights: List[TrendInsight] = Field(default_factory=list)
|
||||||
|
recommendations: List[str] = Field(default_factory=list)
|
||||||
|
outlook: str = "Pending analysis"
|
||||||
|
|
||||||
|
|
||||||
|
class YearlySnapshot(BaseModel):
|
||||||
|
year: int
|
||||||
|
total_revenue: float
|
||||||
|
total_expenses: float
|
||||||
|
revenue_growth: float | None = None
|
||||||
|
expense_growth: float | None = None
|
||||||
|
surplus: float | None = None
|
||||||
|
program_ratio: float | None = None
|
||||||
|
admin_ratio: float | None = None
|
||||||
|
fundraising_ratio: float | None = None
|
||||||
|
net_margin: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AnalystState(BaseModel):
|
||||||
|
organisation_name: str
|
||||||
|
organisation_ein: str
|
||||||
|
series: List[YearlySnapshot]
|
||||||
|
key_metrics: List[TrendMetric]
|
||||||
|
notes: List[str] = Field(default_factory=list)
|
||||||
@@ -106,6 +106,11 @@ class CoreOrganizationMetadata(BaseModel):
|
|||||||
incorporation_state: str = Field(
|
incorporation_state: str = Field(
|
||||||
..., description="State of incorporation.", title="Incorporation State"
|
..., description="State of incorporation.", title="Incorporation State"
|
||||||
)
|
)
|
||||||
|
calendar_year: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Calendar year covered by the return (if different from fiscal year).",
|
||||||
|
title="Calendar Year",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RevenueBreakdown(BaseModel):
|
class RevenueBreakdown(BaseModel):
|
||||||
@@ -579,6 +584,7 @@ def _transform_flat_payload(data: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"organization_type": get_str("organization_type"),
|
"organization_type": get_str("organization_type"),
|
||||||
"year_of_formation": get_str("year_of_formation"),
|
"year_of_formation": get_str("year_of_formation"),
|
||||||
"incorporation_state": get_str("incorporation_state"),
|
"incorporation_state": get_str("incorporation_state"),
|
||||||
|
"calendar_year": get_str("calendar_year"),
|
||||||
},
|
},
|
||||||
"revenue_breakdown": {
|
"revenue_breakdown": {
|
||||||
"total_revenue": get_value("total_revenue"),
|
"total_revenue": get_value("total_revenue"),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ from pydantic_ai.ui.vercel_ai import VercelAIAdapter
|
|||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
|
|
||||||
from app.agents import form_auditor, web_search
|
from app.agents import analyst, form_auditor, web_search
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.extracted_data_service import get_extracted_data_service
|
from app.services.extracted_data_service import get_extracted_data_service
|
||||||
|
|
||||||
@@ -24,27 +25,43 @@ model = OpenAIChatModel(model_name="gpt-4o", provider=provider)
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Deps:
|
class Deps:
|
||||||
extracted_data: dict[str, Any]
|
extracted_data: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
agent = Agent(model=model, deps_type=Deps)
|
agent = Agent(model=model, deps_type=Deps)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/agent", tags=["Agent"])
|
router = APIRouter(prefix="/api/v1/agent", tags=["Agent"])
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@agent.tool
|
@agent.tool
|
||||||
async def build_audit_report(ctx: RunContext[Deps]):
|
async def build_audit_report(ctx: RunContext[Deps]):
|
||||||
"""Calls the audit subagent to get a full audit report of the organization"""
|
"""Calls the audit subagent to get a full audit report of the organization"""
|
||||||
data = ctx.deps.extracted_data
|
data = ctx.deps.extracted_data[0]
|
||||||
|
|
||||||
with open("data/audit_report.json", "w") as f:
|
|
||||||
json.dump(data, f)
|
|
||||||
|
|
||||||
result = await form_auditor.build_audit_report(data)
|
result = await form_auditor.build_audit_report(data)
|
||||||
|
|
||||||
return result.model_dump()
|
return result.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def build_analysis_report(ctx: RunContext[Deps]):
|
||||||
|
"""Calls the analyst subagent to get a full report of the organization's performance across years"""
|
||||||
|
data = ctx.deps.extracted_data
|
||||||
|
if not data:
|
||||||
|
raise ValueError("No extracted data available for analysis.")
|
||||||
|
|
||||||
|
if len(data) == 1:
|
||||||
|
logger.info(
|
||||||
|
"build_analysis_report called with single-year data; report will still be generated but trends may be limited."
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await analyst.build_performance_report(data)
|
||||||
|
|
||||||
|
return result.model_dump()
|
||||||
|
|
||||||
|
|
||||||
@agent.tool_plain
|
@agent.tool_plain
|
||||||
async def search_web_information(query: str, max_results: int = 5):
|
async def search_web_information(query: str, max_results: int = 5):
|
||||||
"""Search the web for up-to-date information using Tavily. Use this when you need current information, news, research, or facts not in your knowledge base."""
|
"""Search the web for up-to-date information using Tavily. Use this when you need current information, news, research, or facts not in your knowledge base."""
|
||||||
@@ -61,6 +78,8 @@ async def chat(request: Request, tema: Annotated[str, Header()]) -> Response:
|
|||||||
|
|
||||||
extracted_data = [doc.get_extracted_data() for doc in data]
|
extracted_data = [doc.get_extracted_data() for doc in data]
|
||||||
|
|
||||||
deps = Deps(extracted_data=extracted_data[0])
|
logger.info(f"Extracted data amount: {len(extracted_data)}")
|
||||||
|
|
||||||
|
deps = Deps(extracted_data=extracted_data)
|
||||||
|
|
||||||
return await VercelAIAdapter.dispatch_request(request, agent=agent, deps=deps)
|
return await VercelAIAdapter.dispatch_request(request, agent=agent, deps=deps)
|
||||||
|
|||||||
@@ -12,6 +12,15 @@
|
|||||||
"max_value": null,
|
"max_value": null,
|
||||||
"pattern": "^\\d{2}-\\d{7}$"
|
"pattern": "^\\d{2}-\\d{7}$"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "calendar_year",
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Calendar year for which the data is reported",
|
||||||
|
"required": true,
|
||||||
|
"min_value": null,
|
||||||
|
"max_value": null,
|
||||||
|
"pattern": null
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "legal_name",
|
"name": "legal_name",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
230
frontend/src/components/AnalystReport.tsx
Normal file
230
frontend/src/components/AnalystReport.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Activity,
|
||||||
|
Info,
|
||||||
|
Target,
|
||||||
|
Lightbulb,
|
||||||
|
ArrowRight,
|
||||||
|
AlertTriangle,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
type TrendDirection = "Improving" | "Declining" | "Stable" | "Volatile";
|
||||||
|
|
||||||
|
interface TrendMetricPoint {
|
||||||
|
year: number;
|
||||||
|
value: number;
|
||||||
|
growth?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrendMetric {
|
||||||
|
name: string;
|
||||||
|
unit: string;
|
||||||
|
description: string;
|
||||||
|
points: TrendMetricPoint[];
|
||||||
|
cagr?: number | null;
|
||||||
|
direction: TrendDirection;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrendInsight {
|
||||||
|
category: string;
|
||||||
|
direction: TrendDirection;
|
||||||
|
summary: string;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnalystReportData {
|
||||||
|
organisation_name: string;
|
||||||
|
organisation_ein: string;
|
||||||
|
years_analyzed: number[];
|
||||||
|
key_metrics: TrendMetric[];
|
||||||
|
insights: TrendInsight[];
|
||||||
|
recommendations: string[];
|
||||||
|
outlook: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnalystReportProps {
|
||||||
|
data: AnalystReportData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directionBadgeClasses: Record<TrendDirection, string> = {
|
||||||
|
Improving: "bg-green-100 text-green-700 border-green-200",
|
||||||
|
Declining: "bg-red-100 text-red-700 border-red-200",
|
||||||
|
Stable: "bg-blue-100 text-blue-700 border-blue-200",
|
||||||
|
Volatile: "bg-yellow-100 text-yellow-700 border-yellow-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
const directionIcon = (direction: TrendDirection) => {
|
||||||
|
switch (direction) {
|
||||||
|
case "Improving":
|
||||||
|
return <TrendingUp className="w-4 h-4" />;
|
||||||
|
case "Declining":
|
||||||
|
return <TrendingDown className="w-4 h-4" />;
|
||||||
|
case "Volatile":
|
||||||
|
return <Activity className="w-4 h-4" />;
|
||||||
|
default:
|
||||||
|
return <Info className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (value: number, unit: string) => {
|
||||||
|
if (unit === "USD") {
|
||||||
|
return `$${value.toLocaleString("en-US", {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
if (unit === "Ratio") {
|
||||||
|
return `${(value * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
return value.toLocaleString("en-US", { maximumFractionDigits: 2 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (value?: number | null) => {
|
||||||
|
if (value === undefined || value === null || Number.isNaN(value)) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
return `${(value * 100).toFixed(1)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AnalystReport({ data }: AnalystReportProps) {
|
||||||
|
const {
|
||||||
|
organisation_name,
|
||||||
|
organisation_ein,
|
||||||
|
years_analyzed,
|
||||||
|
key_metrics,
|
||||||
|
insights,
|
||||||
|
recommendations,
|
||||||
|
outlook,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||||
|
<div className="bg-gradient-to-r from-emerald-50 to-sky-50 px-4 py-3 border-b border-emerald-100">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide">
|
||||||
|
Multi-year performance analysis
|
||||||
|
</p>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
{organisation_name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
EIN {organisation_ein} • {years_analyzed.join(" – ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-sm text-gray-600">
|
||||||
|
<span className="font-medium text-gray-900">Outlook</span>
|
||||||
|
<p className="text-xs text-gray-600 max-w-xs">{outlook}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Metrics */}
|
||||||
|
{key_metrics.length > 0 && (
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-800 flex items-center gap-2">
|
||||||
|
<Activity className="w-4 h-4 text-emerald-600" />
|
||||||
|
Core Trend Metrics
|
||||||
|
</h4>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{key_metrics.slice(0, 4).map((metric, index) => {
|
||||||
|
const latest = metric.points[metric.points.length - 1];
|
||||||
|
const prior =
|
||||||
|
metric.points.length > 1
|
||||||
|
? metric.points[metric.points.length - 2]
|
||||||
|
: null;
|
||||||
|
const yoy = latest && prior ? formatPercent(latest.growth) : "—";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${metric.name}-${index}`}
|
||||||
|
className="border rounded-lg p-3 bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="font-medium text-sm text-gray-900">
|
||||||
|
{metric.name}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full border ${directionBadgeClasses[metric.direction]}`}
|
||||||
|
>
|
||||||
|
{directionIcon(metric.direction)}
|
||||||
|
{metric.direction}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
|
{latest ? formatNumber(latest.value, metric.unit) : "—"}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-between text-xs text-gray-600 mt-1">
|
||||||
|
<span>YoY: {yoy}</span>
|
||||||
|
<span>CAGR: {formatPercent(metric.cagr)}</span>
|
||||||
|
</div>
|
||||||
|
{metric.notes && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2">{metric.notes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Insights */}
|
||||||
|
{insights.length > 0 && (
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-800 mb-2 flex items-center gap-2">
|
||||||
|
<Lightbulb className="w-4 h-4 text-amber-500" />
|
||||||
|
Key Insights
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{insights.map((insight, index) => (
|
||||||
|
<div
|
||||||
|
key={`${insight.category}-${index}`}
|
||||||
|
className="border rounded-lg p-3 bg-white"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||||
|
{directionIcon(insight.direction)}
|
||||||
|
{insight.category}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{Math.round(insight.confidence * 100)}% confidence
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-700">{insight.summary}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recommendations */}
|
||||||
|
{recommendations.length > 0 && (
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-800 mb-2 flex items-center gap-2">
|
||||||
|
<Target className="w-4 h-4 text-indigo-500" />
|
||||||
|
Recommended Actions
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1 text-sm text-gray-700">
|
||||||
|
{recommendations.map((rec, index) => (
|
||||||
|
<li
|
||||||
|
key={`rec-${index}`}
|
||||||
|
className="flex items-start gap-2 bg-gray-50 border border-gray-100 rounded-lg p-2"
|
||||||
|
>
|
||||||
|
<ArrowRight className="w-4 h-4 text-gray-500 mt-0.5" />
|
||||||
|
<span>{rec}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty states fallback */}
|
||||||
|
{recommendations.length === 0 && insights.length === 0 && (
|
||||||
|
<div className="px-4 py-6 text-sm text-gray-600 flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-gray-400" />
|
||||||
|
No trend insights available yet. Try requesting an annual comparison.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
PromptInputTools,
|
PromptInputTools,
|
||||||
} from "@/components/ai-elements/prompt-input";
|
} from "@/components/ai-elements/prompt-input";
|
||||||
import { Action, Actions } from "@/components/ai-elements/actions";
|
import { Action, Actions } from "@/components/ai-elements/actions";
|
||||||
import { Fragment, useState, useEffect } from "react";
|
import { Fragment, useState, useEffect, useMemo } from "react";
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { Response } from "@/components/ai-elements/response";
|
import { Response } from "@/components/ai-elements/response";
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AuditReport } from "./AuditReport";
|
import { AuditReport } from "./AuditReport";
|
||||||
|
import { AnalystReport } from "./AnalystReport";
|
||||||
import { WebSearchResults } from "./WebSearchResults";
|
import { WebSearchResults } from "./WebSearchResults";
|
||||||
import { Loader } from "@/components/ai-elements/loader";
|
import { Loader } from "@/components/ai-elements/loader";
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
@@ -55,7 +56,7 @@ export function ChatTab({ selectedTema }: ChatTabProps) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
setError(`Error en el chat: ${error.message}`);
|
setError(`Chat error: ${error.message}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,20 +84,32 @@ export function ChatTab({ selectedTema }: ChatTabProps) {
|
|||||||
{
|
{
|
||||||
body: {
|
body: {
|
||||||
dataroom: selectedTema,
|
dataroom: selectedTema,
|
||||||
context: `Usuario está consultando sobre el dataroom: ${selectedTema}`,
|
context: `User is asking about the dataroom: ${selectedTema}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
setInput("");
|
setInput("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasActiveToolRequest = useMemo(() => {
|
||||||
|
return messages.some((message) =>
|
||||||
|
message.parts.some(
|
||||||
|
(part: any) =>
|
||||||
|
typeof part?.type === "string" &&
|
||||||
|
part.type.startsWith("tool-") &&
|
||||||
|
part.state === "input-available",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const shouldShowGlobalLoader =
|
||||||
|
(status === "streaming" || status === "loading") && !hasActiveToolRequest;
|
||||||
|
|
||||||
if (!selectedTema) {
|
if (!selectedTema) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-64">
|
<div className="flex flex-col items-center justify-center h-64">
|
||||||
<MessageCircle className="w-12 h-12 text-gray-400 mb-4" />
|
<MessageCircle className="w-12 h-12 text-gray-400 mb-4" />
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">Select a dataroom to start chatting</p>
|
||||||
Selecciona un dataroom para iniciar el chat
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -114,9 +127,9 @@ export function ChatTab({ selectedTema }: ChatTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 bg-gray-50 rounded-lg p-4">
|
<div className="flex-1 bg-gray-50 rounded-lg p-4">
|
||||||
<p className="text-sm text-gray-800">
|
<p className="text-sm text-gray-800">
|
||||||
¡Hola! Soy tu asistente de IA para el dataroom{" "}
|
Hi! I’m your AI assistant for dataroom{" "}
|
||||||
<strong>{selectedTema}</strong>. Puedes hacerme preguntas
|
<strong>{selectedTema}</strong>. Ask me anything about the
|
||||||
sobre los documentos almacenados aquí.
|
stored documents.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,11 +218,11 @@ export function ChatTab({ selectedTema }: ChatTabProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${message.id}-${i}`}
|
key={`${message.id}-${i}`}
|
||||||
className="flex items-center gap-2 p-4 bg-blue-50 rounded-lg border border-blue-200"
|
className="mb-4 flex items-center gap-2 p-4 bg-blue-50 rounded-lg border border-blue-200"
|
||||||
>
|
>
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||||
<span className="text-sm text-blue-700">
|
<span className="text-sm text-blue-700">
|
||||||
Generando reporte de auditoría...
|
Generating audit report…
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -217,7 +230,7 @@ export function ChatTab({ selectedTema }: ChatTabProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${message.id}-${i}`}
|
key={`${message.id}-${i}`}
|
||||||
className="mt-4 w-full"
|
className="mt-4 mb-4 w-full"
|
||||||
>
|
>
|
||||||
<div className="max-w-full overflow-hidden">
|
<div className="max-w-full overflow-hidden">
|
||||||
<AuditReport data={part.output} />
|
<AuditReport data={part.output} />
|
||||||
@@ -228,12 +241,57 @@ export function ChatTab({ selectedTema }: ChatTabProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${message.id}-${i}`}
|
key={`${message.id}-${i}`}
|
||||||
className="p-4 bg-red-50 border border-red-200 rounded-lg"
|
className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AlertCircle className="w-4 h-4 text-red-600" />
|
<AlertCircle className="w-4 h-4 text-red-600" />
|
||||||
<span className="text-sm font-medium text-red-800">
|
<span className="text-sm font-medium text-red-800">
|
||||||
Error generando reporte de auditoría
|
Failed to generate audit report
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-red-600 mt-1">
|
||||||
|
{part.errorText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
case "tool-build_analysis_report":
|
||||||
|
switch (part.state) {
|
||||||
|
case "input-available":
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${message.id}-${i}`}
|
||||||
|
className="mb-4 flex items-center gap-2 p-4 bg-purple-50 rounded-lg border border-purple-200"
|
||||||
|
>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-600"></div>
|
||||||
|
<span className="text-sm text-purple-700">
|
||||||
|
Generating performance analysis…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "output-available":
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${message.id}-${i}`}
|
||||||
|
className="mt-4 mb-4 w-full"
|
||||||
|
>
|
||||||
|
<div className="max-w-full overflow-hidden">
|
||||||
|
<AnalystReport data={part.output} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "output-error":
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${message.id}-${i}`}
|
||||||
|
className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-600" />
|
||||||
|
<span className="text-sm font-medium text-red-800">
|
||||||
|
Failed to generate performance analysis
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-red-600 mt-1">
|
<p className="text-sm text-red-600 mt-1">
|
||||||
@@ -250,11 +308,11 @@ export function ChatTab({ selectedTema }: ChatTabProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${message.id}-${i}`}
|
key={`${message.id}-${i}`}
|
||||||
className="flex items-center gap-2 p-4 bg-green-50 rounded-lg border border-green-200"
|
className="mb-4 flex items-center gap-2 p-4 bg-green-50 rounded-lg border border-green-200"
|
||||||
>
|
>
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600"></div>
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600"></div>
|
||||||
<span className="text-sm text-green-700">
|
<span className="text-sm text-green-700">
|
||||||
Searching the web...
|
Searching the web…
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -262,7 +320,7 @@ export function ChatTab({ selectedTema }: ChatTabProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${message.id}-${i}`}
|
key={`${message.id}-${i}`}
|
||||||
className="mt-4 w-full"
|
className="mt-4 mb-4 w-full"
|
||||||
>
|
>
|
||||||
<div className="max-w-full overflow-hidden">
|
<div className="max-w-full overflow-hidden">
|
||||||
<WebSearchResults data={part.output} />
|
<WebSearchResults data={part.output} />
|
||||||
@@ -273,12 +331,12 @@ export function ChatTab({ selectedTema }: ChatTabProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${message.id}-${i}`}
|
key={`${message.id}-${i}`}
|
||||||
className="p-4 bg-red-50 border border-red-200 rounded-lg"
|
className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AlertCircle className="w-4 h-4 text-red-600" />
|
<AlertCircle className="w-4 h-4 text-red-600" />
|
||||||
<span className="text-sm font-medium text-red-800">
|
<span className="text-sm font-medium text-red-800">
|
||||||
Error searching the web
|
Failed to search the web
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-red-600 mt-1">
|
<p className="text-sm text-red-600 mt-1">
|
||||||
@@ -295,8 +353,7 @@ export function ChatTab({ selectedTema }: ChatTabProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{status === "streaming" && <Loader />}
|
{shouldShowGlobalLoader && <Loader />}
|
||||||
{status === "loading" && <Loader />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -317,7 +374,7 @@ export function ChatTab({ selectedTema }: ChatTabProps) {
|
|||||||
<PromptInputTextarea
|
<PromptInputTextarea
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
value={input}
|
value={input}
|
||||||
placeholder={`Pregunta algo sobre ${selectedTema}...`}
|
placeholder={`Ask something about ${selectedTema}...`}
|
||||||
disabled={status === "streaming" || status === "loading"}
|
disabled={status === "streaming" || status === "loading"}
|
||||||
className="min-h-[60px] resize-none border-0 focus:ring-0 transition-all duration-200 text-base px-4 py-3 bg-white rounded-xl"
|
className="min-h-[60px] resize-none border-0 focus:ring-0 transition-all duration-200 text-base px-4 py-3 bg-white rounded-xl"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -59,9 +59,8 @@ export function DashboardTab({ selectedTema }: DashboardTabProps) {
|
|||||||
const info = await api.getDataroomInfo(selectedTema);
|
const info = await api.getDataroomInfo(selectedTema);
|
||||||
setDataroomInfo(info);
|
setDataroomInfo(info);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage =
|
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||||
err instanceof Error ? err.message : "Error desconocido";
|
setError(`Unable to load dataroom info: ${errorMessage}`);
|
||||||
setError(`Error cargando información: ${errorMessage}`);
|
|
||||||
console.error("Error fetching dataroom info:", err);
|
console.error("Error fetching dataroom info:", err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -70,7 +69,7 @@ export function DashboardTab({ selectedTema }: DashboardTabProps) {
|
|||||||
|
|
||||||
const formatFileTypes = (fileTypes: Record<string, number>) => {
|
const formatFileTypes = (fileTypes: Record<string, number>) => {
|
||||||
const entries = Object.entries(fileTypes);
|
const entries = Object.entries(fileTypes);
|
||||||
if (entries.length === 0) return "Sin archivos";
|
if (entries.length === 0) return "No files";
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
.sort(([, a], [, b]) => b - a) // Sort by count descending
|
.sort(([, a], [, b]) => b - a) // Sort by count descending
|
||||||
@@ -90,9 +89,7 @@ export function DashboardTab({ selectedTema }: DashboardTabProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-64">
|
<div className="flex flex-col items-center justify-center h-64">
|
||||||
<Activity className="w-12 h-12 text-gray-400 mb-4" />
|
<Activity className="w-12 h-12 text-gray-400 mb-4" />
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">Select a dataroom to view its metrics</p>
|
||||||
Selecciona un dataroom para ver las métricas
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -101,7 +98,7 @@ export function DashboardTab({ selectedTema }: DashboardTabProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-64">
|
<div className="flex flex-col items-center justify-center h-64">
|
||||||
<Loader2 className="w-8 h-8 text-blue-600 animate-spin mb-4" />
|
<Loader2 className="w-8 h-8 text-blue-600 animate-spin mb-4" />
|
||||||
<p className="text-gray-600">Cargando métricas...</p>
|
<p className="text-gray-600">Loading metrics…</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -124,24 +121,14 @@ export function DashboardTab({ selectedTema }: DashboardTabProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-64">
|
<div className="flex flex-col items-center justify-center h-64">
|
||||||
<AlertCircle className="w-12 h-12 text-gray-400 mb-4" />
|
<AlertCircle className="w-12 h-12 text-gray-400 mb-4" />
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">Unable to load dataroom information</p>
|
||||||
No se pudo cargar la información del dataroom
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="mb-6">
|
<h4 className="text-md font-semibold text-gray-900 mb-4">Metrics</h4>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
Métricas del Dataroom: {selectedTema}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Vista general del estado y actividad del dataroom
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
{/* Files Count Card */}
|
{/* Files Count Card */}
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
@@ -150,7 +137,7 @@ export function DashboardTab({ selectedTema }: DashboardTabProps) {
|
|||||||
<FileText className="w-5 h-5 text-blue-600" />
|
<FileText className="w-5 h-5 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-600">Archivos</p>
|
<p className="text-sm font-medium text-gray-600">Files</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
{dataroomInfo.file_count}
|
{dataroomInfo.file_count}
|
||||||
</p>
|
</p>
|
||||||
@@ -168,9 +155,7 @@ export function DashboardTab({ selectedTema }: DashboardTabProps) {
|
|||||||
<Database className="w-5 h-5 text-green-600" />
|
<Database className="w-5 h-5 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-600">
|
<p className="text-sm font-medium text-gray-600">Storage</p>
|
||||||
Almacenamiento
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
{dataroomInfo.total_size_mb.toFixed(1)} MB
|
{dataroomInfo.total_size_mb.toFixed(1)} MB
|
||||||
</p>
|
</p>
|
||||||
@@ -188,14 +173,14 @@ export function DashboardTab({ selectedTema }: DashboardTabProps) {
|
|||||||
<Activity className="w-5 h-5 text-purple-600" />
|
<Activity className="w-5 h-5 text-purple-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-600">Vectores</p>
|
<p className="text-sm font-medium text-gray-600">Vectors</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
{dataroomInfo.vector_count ?? 0}
|
{dataroomInfo.vector_count ?? 0}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
{dataroomInfo.collection_exists
|
{dataroomInfo.collection_exists
|
||||||
? "Vectores indexados"
|
? "Indexed vectors"
|
||||||
: "Sin vectores"}
|
: "No vectors"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,10 +193,10 @@ export function DashboardTab({ selectedTema }: DashboardTabProps) {
|
|||||||
<TrendingUp className="w-5 h-5 text-orange-600" />
|
<TrendingUp className="w-5 h-5 text-orange-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-600">Estado</p>
|
<p className="text-sm font-medium text-gray-600">Status</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
{dataroomInfo.collection_exists ? "Activo" : "Inactivo"}
|
{dataroomInfo.collection_exists ? "Active" : "Inactive"}
|
||||||
</p>
|
</p>
|
||||||
{dataroomInfo.collection_exists ? (
|
{dataroomInfo.collection_exists ? (
|
||||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||||
@@ -222,14 +207,13 @@ export function DashboardTab({ selectedTema }: DashboardTabProps) {
|
|||||||
{dataroomInfo.collection_info ? (
|
{dataroomInfo.collection_info ? (
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
{dataroomInfo.collection_info.indexed_vectors_count}/
|
{dataroomInfo.collection_info.indexed_vectors_count}/
|
||||||
{dataroomInfo.collection_info.vectors_count} vectores
|
{dataroomInfo.collection_info.vectors_count} indexed vectors
|
||||||
indexados
|
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
{dataroomInfo.collection_exists
|
{dataroomInfo.collection_exists
|
||||||
? "Colección sin datos"
|
? "Collection has no data"
|
||||||
: "Sin colección"}
|
: "No collection"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -241,7 +225,7 @@ export function DashboardTab({ selectedTema }: DashboardTabProps) {
|
|||||||
{dataroomInfo.recent_files.length > 0 && (
|
{dataroomInfo.recent_files.length > 0 && (
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h4 className="text-md font-semibold text-gray-900 mb-4">
|
<h4 className="text-md font-semibold text-gray-900 mb-4">
|
||||||
Archivos Recientes
|
Recent Files
|
||||||
</h4>
|
</h4>
|
||||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
<div className="divide-y divide-gray-200">
|
<div className="divide-y divide-gray-200">
|
||||||
@@ -258,7 +242,7 @@ export function DashboardTab({ selectedTema }: DashboardTabProps) {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{new Date(file.last_modified).toLocaleDateString(
|
{new Date(file.last_modified).toLocaleDateString(
|
||||||
"es-ES",
|
"en-US",
|
||||||
{
|
{
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
|
|||||||
@@ -87,14 +87,12 @@ export function DataroomView({ onProcessingChange }: DataroomViewProps = {}) {
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mb-2">
|
<h2 className="text-2xl font-semibold text-gray-900 mb-2">
|
||||||
{selectedTema
|
{selectedTema ? `Dataroom: ${selectedTema}` : "Select a dataroom"}
|
||||||
? `Dataroom: ${selectedTema}`
|
|
||||||
: "Selecciona un dataroom"}
|
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
{selectedTema
|
{selectedTema
|
||||||
? "Gestiona archivos, consulta métricas y chatea con IA sobre el contenido"
|
? "Manage files, review metrics, and chat with AI about the content."
|
||||||
: "Selecciona un dataroom de la barra lateral para comenzar"}
|
: "Pick a dataroom from the sidebar to get started."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -6,17 +6,17 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from "@/components/ui/dialog";
|
||||||
import { Trash2, AlertTriangle } from 'lucide-react'
|
import { Trash2, AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
interface DeleteConfirmDialogProps {
|
interface DeleteConfirmDialogProps {
|
||||||
open: boolean
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void;
|
||||||
onConfirm: () => void
|
onConfirm: () => void;
|
||||||
title: string
|
title: string;
|
||||||
description: string
|
description: string;
|
||||||
fileList?: string[]
|
fileList?: string[];
|
||||||
loading?: boolean
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeleteConfirmDialog({
|
export function DeleteConfirmDialog({
|
||||||
@@ -26,7 +26,7 @@ export function DeleteConfirmDialog({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
fileList,
|
fileList,
|
||||||
loading = false
|
loading = false,
|
||||||
}: DeleteConfirmDialogProps) {
|
}: DeleteConfirmDialogProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -41,7 +41,7 @@ export function DeleteConfirmDialog({
|
|||||||
|
|
||||||
{fileList && fileList.length > 0 && (
|
{fileList && fileList.length > 0 && (
|
||||||
<div className="max-h-40 overflow-y-auto bg-gray-50 rounded p-3">
|
<div className="max-h-40 overflow-y-auto bg-gray-50 rounded p-3">
|
||||||
<p className="text-sm font-medium mb-2">Archivos a eliminar:</p>
|
<p className="text-sm font-medium mb-2">Files to delete:</p>
|
||||||
<ul className="text-sm space-y-1">
|
<ul className="text-sm space-y-1">
|
||||||
{fileList.map((filename, index) => (
|
{fileList.map((filename, index) => (
|
||||||
<li key={index} className="flex items-center gap-2">
|
<li key={index} className="flex items-center gap-2">
|
||||||
@@ -59,17 +59,13 @@ export function DeleteConfirmDialog({
|
|||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Cancelar
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="destructive" onClick={onConfirm} disabled={loading}>
|
||||||
variant="destructive"
|
{loading ? "Deleting…" : "Delete"}
|
||||||
onClick={onConfirm}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? 'Eliminando...' : 'Eliminar'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ export function FilesTab({
|
|||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
|
||||||
// Estados para el modal de preview de PDF
|
// PDF preview modal state
|
||||||
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
||||||
const [previewFileUrl, setPreviewFileUrl] = useState<string | null>(null);
|
const [previewFileUrl, setPreviewFileUrl] = useState<string | null>(null);
|
||||||
const [previewFileName, setPreviewFileName] = useState("");
|
const [previewFileName, setPreviewFileName] = useState("");
|
||||||
@@ -69,12 +69,12 @@ export function FilesTab({
|
|||||||
);
|
);
|
||||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||||
|
|
||||||
// Estados para el modal de chunks
|
// Chunk viewer modal state
|
||||||
const [chunkViewerOpen, setChunkViewerOpen] = useState(false);
|
const [chunkViewerOpen, setChunkViewerOpen] = useState(false);
|
||||||
const [chunkFileName, setChunkFileName] = useState("");
|
const [chunkFileName, setChunkFileName] = useState("");
|
||||||
const [chunkFileTema, setChunkFileTema] = useState("");
|
const [chunkFileTema, setChunkFileTema] = useState("");
|
||||||
|
|
||||||
// Estados para chunking
|
// LandingAI chunking state
|
||||||
const [chunkingConfigOpen, setChunkingConfigOpen] = useState(false);
|
const [chunkingConfigOpen, setChunkingConfigOpen] = useState(false);
|
||||||
const [chunkingFileName, setChunkingFileName] = useState("");
|
const [chunkingFileName, setChunkingFileName] = useState("");
|
||||||
const [chunkingFileTema, setChunkingFileTema] = useState("");
|
const [chunkingFileTema, setChunkingFileTema] = useState("");
|
||||||
@@ -123,10 +123,10 @@ export function FilesTab({
|
|||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
|
|
||||||
if (fileToDelete) {
|
if (fileToDelete) {
|
||||||
// Eliminar archivo individual
|
// Delete single file
|
||||||
await api.deleteFile(fileToDelete, selectedTema || undefined);
|
await api.deleteFile(fileToDelete, selectedTema || undefined);
|
||||||
} else {
|
} else {
|
||||||
// Eliminar archivos seleccionados
|
// Delete selected files
|
||||||
const filesToDelete = Array.from(selectedFiles);
|
const filesToDelete = Array.from(selectedFiles);
|
||||||
await api.deleteFiles(filesToDelete, selectedTema || undefined);
|
await api.deleteFiles(filesToDelete, selectedTema || undefined);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -159,9 +159,7 @@ export function FilesTab({
|
|||||||
try {
|
try {
|
||||||
setDownloading(true);
|
setDownloading(true);
|
||||||
const filesToDownload = Array.from(selectedFiles);
|
const filesToDownload = Array.from(selectedFiles);
|
||||||
const zipName = selectedTema
|
const zipName = selectedTema ? `${selectedTema}_files` : "selected_files";
|
||||||
? `${selectedTema}_archivos`
|
|
||||||
: "archivos_seleccionados";
|
|
||||||
await api.downloadMultipleFiles(
|
await api.downloadMultipleFiles(
|
||||||
filesToDownload,
|
filesToDownload,
|
||||||
selectedTema || undefined,
|
selectedTema || undefined,
|
||||||
@@ -234,7 +232,7 @@ export function FilesTab({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filtrar archivos por término de búsqueda
|
// Filter files by search term
|
||||||
const filteredFiles = files.filter((file) =>
|
const filteredFiles = files.filter((file) =>
|
||||||
file.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
file.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
);
|
);
|
||||||
@@ -250,7 +248,7 @@ export function FilesTab({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string): string => {
|
const formatDate = (dateString: string): string => {
|
||||||
return new Date(dateString).toLocaleDateString("es-ES", {
|
return new Date(dateString).toLocaleDateString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -262,15 +260,15 @@ export function FilesTab({
|
|||||||
const getDeleteDialogProps = () => {
|
const getDeleteDialogProps = () => {
|
||||||
if (fileToDelete) {
|
if (fileToDelete) {
|
||||||
return {
|
return {
|
||||||
title: "Eliminar archivo",
|
title: "Delete file",
|
||||||
message: `¿Estás seguro de que deseas eliminar el archivo "${fileToDelete}"?`,
|
message: `Are you sure you want to delete "${fileToDelete}"?`,
|
||||||
fileList: [fileToDelete],
|
fileList: [fileToDelete],
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const filesToDelete = Array.from(selectedFiles);
|
const filesToDelete = Array.from(selectedFiles);
|
||||||
return {
|
return {
|
||||||
title: "Eliminar archivos seleccionados",
|
title: "Delete selected files",
|
||||||
message: `¿Estás seguro de que deseas eliminar ${filesToDelete.length} archivo${filesToDelete.length > 1 ? "s" : ""}?`,
|
message: `Are you sure you want to delete ${filesToDelete.length} file${filesToDelete.length > 1 ? "s" : ""}?`,
|
||||||
fileList: filesToDelete,
|
fileList: filesToDelete,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -280,9 +278,7 @@ export function FilesTab({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-64">
|
<div className="flex flex-col items-center justify-center h-64">
|
||||||
<FileText className="w-12 h-12 text-gray-400 mb-4" />
|
<FileText className="w-12 h-12 text-gray-400 mb-4" />
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">Select a dataroom to view its files</p>
|
||||||
Selecciona un dataroom para ver sus archivos
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -294,7 +290,7 @@ export function FilesTab({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||||
<span className="text-sm text-blue-800">
|
<span className="text-sm text-blue-800">
|
||||||
Procesando archivos con LandingAI...
|
Processing files with LandingAI…
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,7 +301,7 @@ export function FilesTab({
|
|||||||
<div className="relative flex-1 max-w-md">
|
<div className="relative flex-1 max-w-md">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Buscar archivos..."
|
placeholder="Search files..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
@@ -324,7 +320,7 @@ export function FilesTab({
|
|||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
Descargar ({selectedFiles.size})
|
Download ({selectedFiles.size})
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -334,7 +330,7 @@ export function FilesTab({
|
|||||||
className="gap-2 text-red-600 hover:text-red-700"
|
className="gap-2 text-red-600 hover:text-red-700"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
Eliminar ({selectedFiles.size})
|
Delete ({selectedFiles.size})
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -345,7 +341,7 @@ export function FilesTab({
|
|||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
Subir archivo
|
Upload files
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -355,17 +351,17 @@ export function FilesTab({
|
|||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<p className="text-gray-500">Cargando archivos...</p>
|
<p className="text-gray-500">Loading files…</p>
|
||||||
</div>
|
</div>
|
||||||
) : filteredFiles.length === 0 ? (
|
) : filteredFiles.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-64">
|
<div className="flex flex-col items-center justify-center h-64">
|
||||||
<FileText className="w-12 h-12 text-gray-400 mb-4" />
|
<FileText className="w-12 h-12 text-gray-400 mb-4" />
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">
|
||||||
{!selectedTema
|
{!selectedTema
|
||||||
? "Selecciona un dataroom para ver sus archivos"
|
? "Select a dataroom to view its files"
|
||||||
: searchTerm
|
: searchTerm
|
||||||
? "No se encontraron archivos"
|
? "No files match your search"
|
||||||
: "No hay archivos en este dataroom"}
|
: "This dataroom has no files yet"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -387,10 +383,10 @@ export function FilesTab({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>Archivo</TableHead>
|
<TableHead>File</TableHead>
|
||||||
<TableHead>Tamaño</TableHead>
|
<TableHead>Size</TableHead>
|
||||||
<TableHead>Modificado</TableHead>
|
<TableHead>Modified</TableHead>
|
||||||
<TableHead className="text-right">Acciones</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -418,7 +414,7 @@ export function FilesTab({
|
|||||||
onClick={() => handlePreviewFile(file.name)}
|
onClick={() => handlePreviewFile(file.name)}
|
||||||
disabled={loadingPreview}
|
disabled={loadingPreview}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
title="Vista previa"
|
title="Preview"
|
||||||
>
|
>
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -427,7 +423,7 @@ export function FilesTab({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleViewChunks(file.name)}
|
onClick={() => handleViewChunks(file.name)}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
title="Ver chunks"
|
title="View chunks"
|
||||||
>
|
>
|
||||||
<MessageSquare className="w-4 h-4" />
|
<MessageSquare className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -436,7 +432,7 @@ export function FilesTab({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleStartChunking(file.name)}
|
onClick={() => handleStartChunking(file.name)}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
title="Procesar con LandingAI"
|
title="Process with LandingAI"
|
||||||
>
|
>
|
||||||
<Scissors className="w-4 h-4" />
|
<Scissors className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -446,7 +442,7 @@ export function FilesTab({
|
|||||||
onClick={() => handleDownloadFile(file.name)}
|
onClick={() => handleDownloadFile(file.name)}
|
||||||
disabled={downloading}
|
disabled={downloading}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
title="Descargar"
|
title="Download"
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -456,7 +452,7 @@ export function FilesTab({
|
|||||||
onClick={() => handleDeleteFile(file.name)}
|
onClick={() => handleDeleteFile(file.name)}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
className="h-8 w-8 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
|
className="h-8 w-8 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
title="Eliminar"
|
title="Delete"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -503,7 +499,7 @@ export function FilesTab({
|
|||||||
tema={chunkFileTema}
|
tema={chunkFileTema}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal de configuración de chunking con LandingAI */}
|
{/* LandingAI chunking config modal */}
|
||||||
<ChunkingConfigModalLandingAI
|
<ChunkingConfigModalLandingAI
|
||||||
isOpen={chunkingConfigOpen}
|
isOpen={chunkingConfigOpen}
|
||||||
onClose={() => setChunkingConfigOpen(false)}
|
onClose={() => setChunkingConfigOpen(false)}
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription
|
DialogDescription,
|
||||||
} from '@/components/ui/dialog'
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { Download, Loader2, FileText, ExternalLink } from "lucide-react";
|
||||||
Download,
|
|
||||||
Loader2,
|
|
||||||
FileText,
|
|
||||||
ExternalLink
|
|
||||||
} from 'lucide-react'
|
|
||||||
|
|
||||||
interface PDFPreviewModalProps {
|
interface PDFPreviewModalProps {
|
||||||
open: boolean
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void;
|
||||||
fileUrl: string | null
|
fileUrl: string | null;
|
||||||
fileName: string
|
fileName: string;
|
||||||
onDownload?: () => void
|
onDownload?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PDFPreviewModal({
|
export function PDFPreviewModal({
|
||||||
@@ -27,45 +22,40 @@ export function PDFPreviewModal({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
fileUrl,
|
fileUrl,
|
||||||
fileName,
|
fileName,
|
||||||
onDownload
|
onDownload,
|
||||||
}: PDFPreviewModalProps) {
|
}: PDFPreviewModalProps) {
|
||||||
// Estado para manejar el loading del iframe
|
// Track iframe loading state
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Efecto para manejar el timeout del loading
|
// Hide loading if iframe never fires onLoad
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && fileUrl) {
|
if (open && fileUrl) {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
|
|
||||||
// Timeout para ocultar loading automáticamente después de 3 segundos
|
|
||||||
// Algunos iframes no disparan onLoad correctamente
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}, 3000)
|
}, 3000);
|
||||||
|
|
||||||
return () => clearTimeout(timeout)
|
return () => clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
}, [open, fileUrl])
|
}, [open, fileUrl]);
|
||||||
|
|
||||||
// Manejar cuando el iframe termina de cargar
|
|
||||||
const handleIframeLoad = () => {
|
const handleIframeLoad = () => {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Abrir PDF en nueva pestaña
|
|
||||||
const openInNewTab = () => {
|
const openInNewTab = () => {
|
||||||
if (fileUrl) {
|
if (fileUrl) {
|
||||||
window.open(fileUrl, '_blank')
|
window.open(fileUrl, "_blank");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Reiniciar loading cuando cambia el archivo
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
}
|
|
||||||
onOpenChange(open)
|
|
||||||
}
|
}
|
||||||
|
onOpenChange(open);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
@@ -75,81 +65,68 @@ export function PDFPreviewModal({
|
|||||||
<FileText className="w-5 h-5" />
|
<FileText className="w-5 h-5" />
|
||||||
{fileName}
|
{fileName}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>PDF preview</DialogDescription>
|
||||||
Vista previa del documento PDF
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Barra de controles */}
|
{/* Controls */}
|
||||||
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b bg-gray-50">
|
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b bg-gray-50">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={openInNewTab}
|
onClick={openInNewTab}
|
||||||
title="Abrir en nueva pestaña"
|
title="Open in new tab"
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4 mr-2" />
|
<ExternalLink className="w-4 h-4 mr-2" />
|
||||||
Abrir en pestaña nueva
|
Open in new tab
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Botón de descarga */}
|
{/* Download button */}
|
||||||
{onDownload && (
|
{onDownload && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onDownload}
|
onClick={onDownload}
|
||||||
title="Descargar archivo"
|
title="Download file"
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
Descargar
|
Download
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Área de visualización del PDF con iframe */}
|
{/* PDF iframe */}
|
||||||
<div className="flex-1 relative bg-gray-100">
|
<div className="flex-1 relative bg-gray-100 overflow-hidden min-h-0">
|
||||||
{!fileUrl ? (
|
{!fileUrl ? (
|
||||||
<div className="flex items-center justify-center h-full text-center text-gray-500 p-8">
|
<div className="flex items-center justify-center h-full text-center text-gray-500 p-8">
|
||||||
<div>
|
<div>
|
||||||
<FileText className="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
<FileText className="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
||||||
<p>No se ha proporcionado un archivo para previsualizar</p>
|
<p>No file available for preview</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Indicador de carga */}
|
{/* Loading state */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white z-10">
|
<div className="absolute inset-0 flex items-center justify-center bg-white z-10">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Loader2 className="w-12 h-12 animate-spin text-blue-500 mx-auto mb-4" />
|
<Loader2 className="w-12 h-12 animate-spin text-blue-500 mx-auto mb-4" />
|
||||||
<p className="text-gray-600">Cargando PDF...</p>
|
<p className="text-gray-600">Loading PDF…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/*
|
|
||||||
Iframe para mostrar el PDF
|
|
||||||
El navegador maneja toda la visualización, zoom, scroll, etc.
|
|
||||||
Esto muestra el PDF exactamente como se vería si lo abrieras directamente
|
|
||||||
*/}
|
|
||||||
<iframe
|
<iframe
|
||||||
src={fileUrl}
|
src={fileUrl}
|
||||||
className="w-full h-full border-0"
|
className="w-full h-full border-0"
|
||||||
title={`Vista previa de ${fileName}`}
|
title={`Preview of ${fileName}`}
|
||||||
onLoad={handleIframeLoad}
|
onLoad={handleIframeLoad}
|
||||||
style={{ minHeight: '600px' }}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer con información */}
|
|
||||||
<div className="px-6 py-3 border-t bg-gray-50 text-xs text-gray-500 text-center">
|
|
||||||
{fileName}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function Sidebar({
|
|||||||
const handleCreateDataroom = async () => {
|
const handleCreateDataroom = async () => {
|
||||||
const trimmed = newDataroomName.trim();
|
const trimmed = newDataroomName.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
setCreateError("El nombre es obligatorio");
|
setCreateError("Name is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ export function Sidebar({
|
|||||||
setCreateError(
|
setCreateError(
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: "No se pudo crear el dataroom. Inténtalo nuevamente.",
|
: "Could not create the dataroom. Please try again.",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setCreatingDataroom(false);
|
setCreatingDataroom(false);
|
||||||
@@ -168,15 +168,15 @@ export function Sidebar({
|
|||||||
tema: string,
|
tema: string,
|
||||||
e: React.MouseEvent<HTMLButtonElement>,
|
e: React.MouseEvent<HTMLButtonElement>,
|
||||||
) => {
|
) => {
|
||||||
e.stopPropagation(); // Evitar que se seleccione el tema al hacer clic en el icono
|
e.stopPropagation(); // Prevent selecting the dataroom when clicking delete
|
||||||
|
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
`¿Estás seguro de que deseas eliminar el dataroom "${tema}"?\n\n` +
|
`Are you sure you want to delete the dataroom "${tema}"?\n\n` +
|
||||||
`Esto eliminará:\n` +
|
`This will remove:\n` +
|
||||||
`• El dataroom de la base de datos\n` +
|
`• The dataroom from the database\n` +
|
||||||
`• Todos los archivos del tema en Azure Blob Storage\n` +
|
`• All files stored for this topic in Azure Blob Storage\n` +
|
||||||
`• La colección "${tema}" en Qdrant (si existe)\n\n` +
|
`• The "${tema}" collection in Qdrant (if it exists)\n\n` +
|
||||||
`Esta acción no se puede deshacer.`,
|
`This action cannot be undone.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
@@ -191,10 +191,10 @@ export function Sidebar({
|
|||||||
console.error(`Error deleting dataroom "${tema}":`, error);
|
console.error(`Error deleting dataroom "${tema}":`, error);
|
||||||
// If dataroom deletion fails, fall back to legacy deletion
|
// If dataroom deletion fails, fall back to legacy deletion
|
||||||
|
|
||||||
// Eliminar todos los archivos del tema en Azure Blob Storage
|
// Delete all topic files in Azure Blob Storage
|
||||||
await api.deleteTema(tema);
|
await api.deleteTema(tema);
|
||||||
|
|
||||||
// Intentar eliminar la colección en Qdrant (si existe)
|
// Attempt to delete the Qdrant collection (if it exists)
|
||||||
try {
|
try {
|
||||||
const collectionExists = await api.checkCollectionExists(tema);
|
const collectionExists = await api.checkCollectionExists(tema);
|
||||||
if (collectionExists.exists) {
|
if (collectionExists.exists) {
|
||||||
@@ -202,7 +202,7 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
} catch (collectionError) {
|
} catch (collectionError) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`No se pudo eliminar la colección "${tema}" de Qdrant:`,
|
`Could not delete the "${tema}" collection from Qdrant:`,
|
||||||
collectionError,
|
collectionError,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -216,9 +216,9 @@ export function Sidebar({
|
|||||||
setSelectedTema(null);
|
setSelectedTema(null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error eliminando dataroom "${tema}":`, error);
|
console.error(`Error deleting dataroom "${tema}":`, error);
|
||||||
alert(
|
alert(
|
||||||
`Error al eliminar el dataroom: ${error instanceof Error ? error.message : "Error desconocido"}`,
|
`Unable to delete dataroom: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingTema(null);
|
setDeletingTema(null);
|
||||||
@@ -251,9 +251,7 @@ export function Sidebar({
|
|||||||
className="text-slate-400 hover:text-slate-100"
|
className="text-slate-400 hover:text-slate-100"
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={
|
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
collapsed ? "Expandir barra lateral" : "Contraer barra lateral"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
@@ -279,7 +277,7 @@ export function Sidebar({
|
|||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
{renderWithTooltip(
|
{renderWithTooltip(
|
||||||
"Crear dataroom",
|
"Create",
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -293,15 +291,15 @@ export function Sidebar({
|
|||||||
disabled={disabled || creatingDataroom}
|
disabled={disabled || creatingDataroom}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
{!collapsed && <span>Crear dataroom</span>}
|
{!collapsed && <span>Create</span>}
|
||||||
</Button>,
|
</Button>,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lista de temas */}
|
{/* Dataroom list */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-sm text-slate-400 px-3 py-2 text-center">
|
<div className="text-sm text-slate-400 px-3 py-2 text-center">
|
||||||
{collapsed ? "..." : "Cargando..."}
|
{collapsed ? "..." : "Loading..."}
|
||||||
</div>
|
</div>
|
||||||
) : Array.isArray(temas) && temas.length > 0 ? (
|
) : Array.isArray(temas) && temas.length > 0 ? (
|
||||||
temas.map((tema) => (
|
temas.map((tema) => (
|
||||||
@@ -331,7 +329,7 @@ export function Sidebar({
|
|||||||
onClick={(e) => handleDeleteTema(tema, e)}
|
onClick={(e) => handleDeleteTema(tema, e)}
|
||||||
disabled={deletingTema === tema || disabled}
|
disabled={deletingTema === tema || disabled}
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded hover:bg-red-500/20 opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded hover:bg-red-500/20 opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
|
||||||
title="Eliminar dataroom y colección"
|
title="Delete dataroom and collection"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-red-400" />
|
<Trash2 className="h-4 w-4 text-red-400" />
|
||||||
</button>
|
</button>
|
||||||
@@ -341,8 +339,8 @@ export function Sidebar({
|
|||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-slate-400 px-3 py-2 text-center">
|
<div className="text-sm text-slate-400 px-3 py-2 text-center">
|
||||||
{Array.isArray(temas) && temas.length === 0
|
{Array.isArray(temas) && temas.length === 0
|
||||||
? "No hay datarooms"
|
? "No datarooms found"
|
||||||
: "Cargando datarooms..."}
|
: "Loading datarooms..."}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -357,7 +355,7 @@ export function Sidebar({
|
|||||||
>
|
>
|
||||||
{onNavigateToSchemas &&
|
{onNavigateToSchemas &&
|
||||||
renderWithTooltip(
|
renderWithTooltip(
|
||||||
"Gestionar Schemas",
|
"Manage schemas",
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -370,12 +368,12 @@ export function Sidebar({
|
|||||||
>
|
>
|
||||||
<Database className={cn("h-4 w-4", !collapsed && "mr-2")} />
|
<Database className={cn("h-4 w-4", !collapsed && "mr-2")} />
|
||||||
<span className={cn(collapsed && "sr-only")}>
|
<span className={cn(collapsed && "sr-only")}>
|
||||||
Gestionar Schemas
|
Manage Schemas
|
||||||
</span>
|
</span>
|
||||||
</Button>,
|
</Button>,
|
||||||
)}
|
)}
|
||||||
{renderWithTooltip(
|
{renderWithTooltip(
|
||||||
"Actualizar datarooms",
|
"Refresh datarooms",
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -388,7 +386,7 @@ export function Sidebar({
|
|||||||
>
|
>
|
||||||
<RefreshCcw className={cn("mr-2 h-4 w-4", collapsed && "mr-0")} />
|
<RefreshCcw className={cn("mr-2 h-4 w-4", collapsed && "mr-0")} />
|
||||||
<span className={cn(collapsed && "sr-only")}>
|
<span className={cn(collapsed && "sr-only")}>
|
||||||
Actualizar datarooms
|
Refresh datarooms
|
||||||
</span>
|
</span>
|
||||||
</Button>,
|
</Button>,
|
||||||
)}
|
)}
|
||||||
@@ -403,14 +401,14 @@ export function Sidebar({
|
|||||||
aria-describedby="create-dataroom-description"
|
aria-describedby="create-dataroom-description"
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Crear dataroom</DialogTitle>
|
<DialogTitle>Create dataroom</DialogTitle>
|
||||||
<DialogDescription id="create-dataroom-description">
|
<DialogDescription id="create-dataroom-description">
|
||||||
Define un nombre único para organizar tus archivos.
|
Choose a unique name to organize your files.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="dataroom-name">Nombre del dataroom</Label>
|
<Label htmlFor="dataroom-name">Dataroom name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="dataroom-name"
|
id="dataroom-name"
|
||||||
value={newDataroomName}
|
value={newDataroomName}
|
||||||
@@ -420,7 +418,7 @@ export function Sidebar({
|
|||||||
setCreateError(null);
|
setCreateError(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Ej: normativa, contratos, fiscal..."
|
placeholder="e.g., policies, contracts, finance..."
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{createError && (
|
{createError && (
|
||||||
@@ -434,13 +432,13 @@ export function Sidebar({
|
|||||||
onClick={() => handleCreateDialogOpenChange(false)}
|
onClick={() => handleCreateDialogOpenChange(false)}
|
||||||
disabled={creatingDataroom}
|
disabled={creatingDataroom}
|
||||||
>
|
>
|
||||||
Cancelar
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateDataroom}
|
onClick={handleCreateDataroom}
|
||||||
disabled={creatingDataroom || newDataroomName.trim() === ""}
|
disabled={creatingDataroom || newDataroomName.trim() === ""}
|
||||||
>
|
>
|
||||||
{creatingDataroom ? "Creando..." : "Crear dataroom"}
|
{creatingDataroom ? "Creating…" : "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -300,3 +300,492 @@ $20
|
|||||||
extraction_timestamp
|
extraction_timestamp
|
||||||
$26
|
$26
|
||||||
2025-11-08T16:17:31.137820
|
2025-11-08T16:17:31.137820
|
||||||
|
*2
|
||||||
|
$6
|
||||||
|
SELECT
|
||||||
|
$1
|
||||||
|
0
|
||||||
|
*14
|
||||||
|
$4
|
||||||
|
HSET
|
||||||
|
$44
|
||||||
|
extracted_doc:doc:01K9MH40SHHNWXNGAP4MDX6HZF
|
||||||
|
$2
|
||||||
|
pk
|
||||||
|
$26
|
||||||
|
01K9MH40SHHNWXNGAP4MDX6HZF
|
||||||
|
$9
|
||||||
|
file_name
|
||||||
|
$21
|
||||||
|
tax_year_2019_990.pdf
|
||||||
|
$4
|
||||||
|
tema
|
||||||
|
$4
|
||||||
|
ABBY
|
||||||
|
$15
|
||||||
|
collection_name
|
||||||
|
$4
|
||||||
|
ABBY
|
||||||
|
$19
|
||||||
|
extracted_data_json
|
||||||
|
$4954
|
||||||
|
{
|
||||||
|
"ein": "31-0329725",
|
||||||
|
"legal_name": "Abbey Credit Union Inc",
|
||||||
|
"phone_number": "937-898-7800",
|
||||||
|
"website_url": "www.abbeycu.com",
|
||||||
|
"return_type": "990",
|
||||||
|
"amended_return": "",
|
||||||
|
"group_exemption_number": "",
|
||||||
|
"subsection_code": "501(c)(14)",
|
||||||
|
"ruling_date": "",
|
||||||
|
"accounting_method": "Accrual",
|
||||||
|
"organization_type": "Corporation",
|
||||||
|
"year_of_formation": "1937",
|
||||||
|
"incorporation_state": "OH",
|
||||||
|
"total_revenue": 4659611,
|
||||||
|
"contributions_gifts_grants": 0,
|
||||||
|
"program_service_revenue": 4114069,
|
||||||
|
"membership_dues": 0,
|
||||||
|
"investment_income": 545542,
|
||||||
|
"gains_losses_sales_assets": 0,
|
||||||
|
"rental_income": 6725,
|
||||||
|
"related_organizations_revenue": 0,
|
||||||
|
"gaming_revenue": 0,
|
||||||
|
"other_revenue": 0,
|
||||||
|
"government_grants": 0,
|
||||||
|
"foreign_contributions": 0,
|
||||||
|
"total_expenses": 4159254,
|
||||||
|
"program_services_expenses": 0,
|
||||||
|
"management_general_expenses": 0,
|
||||||
|
"fundraising_expenses": 0,
|
||||||
|
"grants_us_organizations": 0,
|
||||||
|
"grants_us_individuals": 0,
|
||||||
|
"grants_foreign_organizations": 0,
|
||||||
|
"grants_foreign_individuals": 0,
|
||||||
|
"compensation_officers": 338208,
|
||||||
|
"compensation_other_staff": 1018956,
|
||||||
|
"payroll_taxes_benefits": 309032,
|
||||||
|
"professional_fees": 34366,
|
||||||
|
"office_occupancy_costs": 0,
|
||||||
|
"information_technology_costs": 0,
|
||||||
|
"travel_conference_expenses": 0,
|
||||||
|
"depreciation_amortization": 0,
|
||||||
|
"insurance": 0,
|
||||||
|
"officers_list": [
|
||||||
|
"Michael Thein, Chairman",
|
||||||
|
"Nancy Wood, Vice Chairman",
|
||||||
|
"Steve Wilmoth, Treasurer",
|
||||||
|
"Julie Trick, Secretary",
|
||||||
|
"Michele Blake, Board Member",
|
||||||
|
"Cheryl Saunders, Board Member",
|
||||||
|
"Heather Scaggs-Richardson, Board Member",
|
||||||
|
"Dean Pielemeier, CEO",
|
||||||
|
"Teri Puthoff, VP of Finance",
|
||||||
|
"Blanca Ortiz, VP of Business Development"
|
||||||
|
],
|
||||||
|
"governing_body_size": 7,
|
||||||
|
"independent_members": 7,
|
||||||
|
"financial_statements_reviewed": "Yes",
|
||||||
|
"form_990_provided_to_governing_body": "Yes",
|
||||||
|
"conflict_of_interest_policy": "Yes",
|
||||||
|
"whistleblower_policy": "Yes",
|
||||||
|
"document_retention_policy": "Yes",
|
||||||
|
"ceo_compensation_review_process": "Compensation for Dean Pielemeier, CEO is reviewed by the Personnel Committee of the board of directors and is acted upon by the board of directors per committee recommendation Compensation for officers is determined by Dean Pielemeier, CEO",
|
||||||
|
"public_disclosure_practices": "Documents are not made available to the public",
|
||||||
|
"program_accomplishments_list": [
|
||||||
|
"Our mission is to help our members improve their economic well-being and quality of life by being competitive, convenient, and cutting edge",
|
||||||
|
"LENDING - ABBEY PROVIDES PERSONAL, VEHICLE, MORTGAGE, AND CREDIT CARD LOANS TO ITS MEMBERS.",
|
||||||
|
"FINANCIAL SERVICES - ABBEY PROVIDES CHECKING ACCOUNTS, SAVINGS ACCOUNTS, MONEY MARKET ACCOUNTS"
|
||||||
|
],
|
||||||
|
"total_fundraising_event_revenue": 0,
|
||||||
|
"total_fundraising_event_expenses": 0,
|
||||||
|
"professional_fundraiser_fees": 0,
|
||||||
|
"number_of_employees": 36,
|
||||||
|
"number_of_volunteers": 7,
|
||||||
|
"occupancy_costs": 0,
|
||||||
|
"fundraising_method_descriptions": "",
|
||||||
|
"joint_ventures_disregarded_entities": "",
|
||||||
|
"base_compensation": 154796,
|
||||||
|
"bonus": 6409,
|
||||||
|
"incentive": 0,
|
||||||
|
"other_compensation": 4642,
|
||||||
|
"non_fixed_compensation": "",
|
||||||
|
"first_class_travel": "",
|
||||||
|
"housing_allowance": "",
|
||||||
|
"expense_account_usage": "",
|
||||||
|
"supplemental_retirement": "Yes",
|
||||||
|
"lobbying_expenditures_direct": 0,
|
||||||
|
"lobbying_expenditures_grassroots": 0,
|
||||||
|
"election_501h_status": "",
|
||||||
|
"political_campaign_expenditures": 0,
|
||||||
|
"related_organizations_affiliates": "",
|
||||||
|
"investment_types": "",
|
||||||
|
"donor_restricted_endowment_values": 0,
|
||||||
|
"net_appreciation_depreciation": 281072,
|
||||||
|
"related_organization_transactions": "",
|
||||||
|
"loans_to_from_related_parties": "LYNN COOK, PRIOR CEO, LIFE INSURAN, Loan from organization, $140,000 balance due",
|
||||||
|
"penalties_excise_taxes_reported": "",
|
||||||
|
"unrelated_business_income_disclosure": "Yes",
|
||||||
|
"foreign_bank_account_reporting": "No",
|
||||||
|
"schedule_o_narrative_explanations": "Our mission is to help our members improve their economic well-being and quality of life by being competitive, convenient, and cutting edge. Membership to Abbey is open to those who live, work, or worship, or attend school in Montgomery, Miami, Shelby, Darke, or Greene counties. Abbey Credit Union is owned by these people that open an account at Abbey CU. Yes - Each member gets one vote at the annual election. Yes - Pursuant to the regulation of the Ohio Division of Credit Unions. The VP of Finance prepared the Form 990 based on financial records Financial statements are audited annually The CEO reviews the returns prior to filing. The Credit Union has a written conflict of interest policy that states that board members are responsible for disclosing possible conflicts of interest as they arise This is reviewed annually. Compensation for Dean Pielemeier, CEO is reviewed by the Personnel Committee of the board of directors and is acted upon by the board of directors per committee recommendation Compensation for officers is determined by Dean Pielemeier, CEO."
|
||||||
|
}
|
||||||
|
$20
|
||||||
|
extraction_timestamp
|
||||||
|
$26
|
||||||
|
2025-11-09T14:42:59.494840
|
||||||
|
*10
|
||||||
|
$4
|
||||||
|
HSET
|
||||||
|
$56
|
||||||
|
:app.models.dataroom.DataRoom:01K9MNBVRY6KVF4XCK70JF70T0
|
||||||
|
$2
|
||||||
|
pk
|
||||||
|
$26
|
||||||
|
01K9MNBVRY6KVF4XCK70JF70T0
|
||||||
|
$4
|
||||||
|
name
|
||||||
|
$5
|
||||||
|
ABBEY
|
||||||
|
$10
|
||||||
|
collection
|
||||||
|
$5
|
||||||
|
abbey
|
||||||
|
$7
|
||||||
|
storage
|
||||||
|
$5
|
||||||
|
abbey
|
||||||
|
*14
|
||||||
|
$4
|
||||||
|
HSET
|
||||||
|
$44
|
||||||
|
extracted_doc:doc:01K9MNMNYH1MYP0TQ5SWZRS5BG
|
||||||
|
$2
|
||||||
|
pk
|
||||||
|
$26
|
||||||
|
01K9MNMNYH1MYP0TQ5SWZRS5BG
|
||||||
|
$9
|
||||||
|
file_name
|
||||||
|
$21
|
||||||
|
tax_year_2022_990.pdf
|
||||||
|
$4
|
||||||
|
tema
|
||||||
|
$5
|
||||||
|
ABBEY
|
||||||
|
$15
|
||||||
|
collection_name
|
||||||
|
$5
|
||||||
|
ABBEY
|
||||||
|
$19
|
||||||
|
extracted_data_json
|
||||||
|
$5525
|
||||||
|
{
|
||||||
|
"ein": "31-0329725",
|
||||||
|
"calendar_year": 2022,
|
||||||
|
"legal_name": "0220 ABBEY CREDIT UNION INC",
|
||||||
|
"phone_number": "(397) 898-7800",
|
||||||
|
"website_url": "www.abbeycu.com",
|
||||||
|
"return_type": "990",
|
||||||
|
"amended_return": "",
|
||||||
|
"group_exemption_number": "",
|
||||||
|
"subsection_code": "501(c)(14)",
|
||||||
|
"ruling_date": "",
|
||||||
|
"accounting_method": "Accrual",
|
||||||
|
"organization_type": "Corporation",
|
||||||
|
"year_of_formation": "1937",
|
||||||
|
"incorporation_state": "OH",
|
||||||
|
"total_revenue": 6146738,
|
||||||
|
"contributions_gifts_grants": 0,
|
||||||
|
"program_service_revenue": 5656278,
|
||||||
|
"membership_dues": 0,
|
||||||
|
"investment_income": 490460,
|
||||||
|
"gains_losses_sales_assets": 0,
|
||||||
|
"rental_income": 0,
|
||||||
|
"related_organizations_revenue": 0,
|
||||||
|
"gaming_revenue": 0,
|
||||||
|
"other_revenue": 0,
|
||||||
|
"government_grants": 0,
|
||||||
|
"foreign_contributions": 0,
|
||||||
|
"total_expenses": 5526970,
|
||||||
|
"program_services_expenses": 386454,
|
||||||
|
"management_general_expenses": 5140516,
|
||||||
|
"fundraising_expenses": 0,
|
||||||
|
"grants_us_organizations": 0,
|
||||||
|
"grants_us_individuals": 0,
|
||||||
|
"grants_foreign_organizations": 0,
|
||||||
|
"grants_foreign_individuals": 0,
|
||||||
|
"compensation_officers": 629393,
|
||||||
|
"compensation_other_staff": 1329887,
|
||||||
|
"payroll_taxes_benefits": 452511,
|
||||||
|
"professional_fees": 96462,
|
||||||
|
"office_occupancy_costs": 292244,
|
||||||
|
"information_technology_costs": 287125,
|
||||||
|
"travel_conference_expenses": 26662,
|
||||||
|
"depreciation_amortization": 236836,
|
||||||
|
"insurance": 39570,
|
||||||
|
"officers_list": [
|
||||||
|
"Lisa Burk, Chief Experience Officer, 40.00 hrs/wk, Officer, $73,286 compensation, $8,931 other compensation",
|
||||||
|
"Eric Stetzel, VP of Business Services, 40.00 hrs/wk, Key employee, $111,064 compensation, $15,979 other compensation",
|
||||||
|
"Dean Pielemeier, CEO, 40.00 hrs/wk, Officer, $212,994 compensation, $20,202 other compensation",
|
||||||
|
"Blanca Criner, Chief Marketing and Business Development Officer, 40.00 hrs/wk, Officer, $121,036 compensation, $4,540 other compensation",
|
||||||
|
"Teri Puthoff, CFO, 40.00 hrs/wk, Officer, $120,962 compensation, $15,754 other compensation",
|
||||||
|
"Michael Thein, Chairman, 1.00 hrs/wk, Individual trustee or director, $0 compensation",
|
||||||
|
"Latham Farley, Board Member, 1.00 hrs/wk, Individual trustee or director, $0 compensation",
|
||||||
|
"Steve Wilmoth, Treasurer, 1.00 hrs/wk, Individual trustee or director, $0 compensation",
|
||||||
|
"Julie Trick, Secretary, 1.00 hrs/wk, Individual trustee or director, $0 compensation",
|
||||||
|
"Michele Blake, Board Member, 1.00 hrs/wk, Individual trustee or director, $0 compensation",
|
||||||
|
"Cheryl Saunders, Board Member, 1.00 hrs/wk, Individual trustee or director, $0 compensation",
|
||||||
|
"Heather Scaggs-Richardson, Vice Chairman, 1.00 hrs/wk, Individual trustee or director, $0 compensation"
|
||||||
|
],
|
||||||
|
"governing_body_size": 7,
|
||||||
|
"independent_members": 7,
|
||||||
|
"financial_statements_reviewed": "No",
|
||||||
|
"form_990_provided_to_governing_body": "Yes",
|
||||||
|
"conflict_of_interest_policy": "Yes",
|
||||||
|
"whistleblower_policy": "Yes",
|
||||||
|
"document_retention_policy": "Yes",
|
||||||
|
"ceo_compensation_review_process": "Approval of Compensation Committee and Board of Directors",
|
||||||
|
"public_disclosure_practices": "Audited financial statements are handed out each year at the annual meeting. Other documents are not made available to the public unless requested.",
|
||||||
|
"program_accomplishments_list": [
|
||||||
|
"Abbey provides checking savings accounts money market accounts certificates of deposit and IRAs. Abbey also offers free atm debit card services wire transfer services mobile banking and insurance related products.",
|
||||||
|
"Abbey provides personal vehicle mortgage and credit card loans to its members."
|
||||||
|
],
|
||||||
|
"total_fundraising_event_revenue": 0,
|
||||||
|
"total_fundraising_event_expenses": 0,
|
||||||
|
"professional_fundraiser_fees": 0,
|
||||||
|
"number_of_employees": 50,
|
||||||
|
"number_of_volunteers": 7,
|
||||||
|
"occupancy_costs": 132399,
|
||||||
|
"fundraising_method_descriptions": "",
|
||||||
|
"joint_ventures_disregarded_entities": "",
|
||||||
|
"base_compensation": 544779,
|
||||||
|
"bonus": 34315,
|
||||||
|
"incentive": 59692,
|
||||||
|
"other_compensation": 0,
|
||||||
|
"non_fixed_compensation": "",
|
||||||
|
"first_class_travel": "No",
|
||||||
|
"housing_allowance": "No",
|
||||||
|
"expense_account_usage": "No",
|
||||||
|
"supplemental_retirement": "No",
|
||||||
|
"lobbying_expenditures_direct": 0,
|
||||||
|
"lobbying_expenditures_grassroots": 0,
|
||||||
|
"election_501h_status": "",
|
||||||
|
"political_campaign_expenditures": 0,
|
||||||
|
"related_organizations_affiliates": "",
|
||||||
|
"investment_types": "Publicly traded securities",
|
||||||
|
"donor_restricted_endowment_values": 0,
|
||||||
|
"net_appreciation_depreciation": -2190939,
|
||||||
|
"related_organization_transactions": "",
|
||||||
|
"loans_to_from_related_parties": "Lynn Cook (Retired CEO) and Dean Pielemeier (CEO) received life insurance loans from the organization, each with a balance due of $1,400,000 and $1,239,997 respectively.",
|
||||||
|
"penalties_excise_taxes_reported": "No",
|
||||||
|
"unrelated_business_income_disclosure": "Yes",
|
||||||
|
"foreign_bank_account_reporting": "No",
|
||||||
|
"schedule_o_narrative_explanations": "Membership to Abbey is open to those who live work worship or attend school in Montgomery Miami Shelby Darke or Greene counties. Abbey CU is owned by the people who open a savings account at Abbey CU. Each member has one vote at the annual election of the Board of Directors. Decisions that require their approval: Ohio Division of Financial Institutions Pursuant to regulation. CFO prepares the form and CEO reviews before submitting. Approval of Compensation Committee and Board of Directors for CEO compensation. Audited financial statements are handed out each year at the annual meeting. Other documents are not made available to the public unless requested."
|
||||||
|
}
|
||||||
|
$20
|
||||||
|
extraction_timestamp
|
||||||
|
$26
|
||||||
|
2025-11-09T16:01:59.759953
|
||||||
|
*14
|
||||||
|
$4
|
||||||
|
HSET
|
||||||
|
$44
|
||||||
|
extracted_doc:doc:01K9MP8DBNWMRH9A966RRJ4E7V
|
||||||
|
$2
|
||||||
|
pk
|
||||||
|
$26
|
||||||
|
01K9MP8DBNWMRH9A966RRJ4E7V
|
||||||
|
$9
|
||||||
|
file_name
|
||||||
|
$21
|
||||||
|
tax_year_2019_990.pdf
|
||||||
|
$4
|
||||||
|
tema
|
||||||
|
$5
|
||||||
|
ABBEY
|
||||||
|
$15
|
||||||
|
collection_name
|
||||||
|
$5
|
||||||
|
ABBEY
|
||||||
|
$19
|
||||||
|
extracted_data_json
|
||||||
|
$4959
|
||||||
|
{
|
||||||
|
"ein": "31-0329725",
|
||||||
|
"calendar_year": 2019,
|
||||||
|
"legal_name": "Abbey Credit Union Inc",
|
||||||
|
"phone_number": "937-898-7800",
|
||||||
|
"website_url": "www.abbeycu.com",
|
||||||
|
"return_type": "990",
|
||||||
|
"amended_return": "",
|
||||||
|
"group_exemption_number": "",
|
||||||
|
"subsection_code": "501(c)(14)",
|
||||||
|
"ruling_date": "",
|
||||||
|
"accounting_method": "Accrual",
|
||||||
|
"organization_type": "Corporation",
|
||||||
|
"year_of_formation": "1937",
|
||||||
|
"incorporation_state": "OH",
|
||||||
|
"total_revenue": 4659611,
|
||||||
|
"contributions_gifts_grants": 0,
|
||||||
|
"program_service_revenue": 4114069,
|
||||||
|
"membership_dues": 0,
|
||||||
|
"investment_income": 545542,
|
||||||
|
"gains_losses_sales_assets": 0,
|
||||||
|
"rental_income": 6725,
|
||||||
|
"related_organizations_revenue": 0,
|
||||||
|
"gaming_revenue": 0,
|
||||||
|
"other_revenue": 0,
|
||||||
|
"government_grants": 0,
|
||||||
|
"foreign_contributions": 0,
|
||||||
|
"total_expenses": 4159254,
|
||||||
|
"program_services_expenses": 0,
|
||||||
|
"management_general_expenses": 0,
|
||||||
|
"fundraising_expenses": 0,
|
||||||
|
"grants_us_organizations": 0,
|
||||||
|
"grants_us_individuals": 0,
|
||||||
|
"grants_foreign_organizations": 0,
|
||||||
|
"grants_foreign_individuals": 0,
|
||||||
|
"compensation_officers": 338208,
|
||||||
|
"compensation_other_staff": 1018956,
|
||||||
|
"payroll_taxes_benefits": 273057,
|
||||||
|
"professional_fees": 34366,
|
||||||
|
"office_occupancy_costs": 200152,
|
||||||
|
"information_technology_costs": 0,
|
||||||
|
"travel_conference_expenses": 46563,
|
||||||
|
"depreciation_amortization": 0,
|
||||||
|
"insurance": 0,
|
||||||
|
"officers_list": {
|
||||||
|
"value": [
|
||||||
|
"Michael Thein, Chairman",
|
||||||
|
"Nancy Wood, Vice Chairman",
|
||||||
|
"Steve Wilmoth, Treasurer",
|
||||||
|
"Julie Trick, Secretary",
|
||||||
|
"Michele Blake, Board Member",
|
||||||
|
"Cheryl Saunders, Board Member",
|
||||||
|
"Heather Scaggs-Richardson, Board Member",
|
||||||
|
"Dean Pielemeier, CEO",
|
||||||
|
"Teri Puthoff, VP of Finance",
|
||||||
|
"Blanca Ortiz, VP of Business Development"
|
||||||
|
],
|
||||||
|
"chunk_references": []
|
||||||
|
},
|
||||||
|
"governing_body_size": 7,
|
||||||
|
"independent_members": 7,
|
||||||
|
"financial_statements_reviewed": "Yes",
|
||||||
|
"form_990_provided_to_governing_body": "Yes",
|
||||||
|
"conflict_of_interest_policy": "Yes",
|
||||||
|
"whistleblower_policy": "Yes",
|
||||||
|
"document_retention_policy": "Yes",
|
||||||
|
"ceo_compensation_review_process": "Compensation for Dean Pielemeier, CEO is reviewed by the Personnel Committee of the board of directors and is acted upon by the board of directors per committee recommendation.",
|
||||||
|
"public_disclosure_practices": "Documents are not made available to the public",
|
||||||
|
"program_accomplishments_list": {
|
||||||
|
"value": [
|
||||||
|
"LENDING - ABBEY PROVIDES PERSONAL, VEHICLE, MORTGAGE, AND CREDIT CARD LOANS TO ITS MEMBERS.",
|
||||||
|
"FINANCIAL SERVICES - ABBEY PROVIDES CHECKING ACCOUNTS, SAVINGS ACCOUNTS, MONEY MARKET ACCOUNTS, ..."
|
||||||
|
],
|
||||||
|
"chunk_references": []
|
||||||
|
},
|
||||||
|
"total_fundraising_event_revenue": 0,
|
||||||
|
"total_fundraising_event_expenses": 0,
|
||||||
|
"professional_fundraiser_fees": 0,
|
||||||
|
"number_of_employees": 36,
|
||||||
|
"number_of_volunteers": 7,
|
||||||
|
"occupancy_costs": 87912,
|
||||||
|
"fundraising_method_descriptions": "",
|
||||||
|
"joint_ventures_disregarded_entities": "",
|
||||||
|
"base_compensation": 154796,
|
||||||
|
"bonus": 6409,
|
||||||
|
"incentive": 0,
|
||||||
|
"other_compensation": 4642,
|
||||||
|
"non_fixed_compensation": "",
|
||||||
|
"first_class_travel": "",
|
||||||
|
"housing_allowance": "",
|
||||||
|
"expense_account_usage": "",
|
||||||
|
"supplemental_retirement": "Yes",
|
||||||
|
"lobbying_expenditures_direct": 0,
|
||||||
|
"lobbying_expenditures_grassroots": 0,
|
||||||
|
"election_501h_status": "",
|
||||||
|
"political_campaign_expenditures": 0,
|
||||||
|
"related_organizations_affiliates": "",
|
||||||
|
"investment_types": "",
|
||||||
|
"donor_restricted_endowment_values": 0,
|
||||||
|
"net_appreciation_depreciation": 281072,
|
||||||
|
"related_organization_transactions": "",
|
||||||
|
"loans_to_from_related_parties": "Loans to prior CEO Lynn Cook for life insurance, $1,400,000 total outstanding.",
|
||||||
|
"penalties_excise_taxes_reported": "",
|
||||||
|
"unrelated_business_income_disclosure": "Yes",
|
||||||
|
"foreign_bank_account_reporting": "No",
|
||||||
|
"schedule_o_narrative_explanations": "Our mission is to help our members improve their economic well-being and quality of life by being competitive, convenient, and cutting edge. Membership to Abbey is open to those who live, work, or worship, or attend school in Montgomery, Miami, Shelby, Darke, or Greene counties. Abbey Credit Union is owned by these people that open an account at Abbey CU. Yes - Each member gets one vote at the annual election. Yes - Pursuant to the regulation of the Ohio Division of Credit Unions. The VP of Finance prepared the Form 990 based on financial records. Financial statements are audited annually. The CEO reviews the returns prior to filing. The Credit Union has a written conflict of interest policy that states that board members are responsible for disclosing possible conflicts of interest as they arise. This is reviewed annually. Compensation for Dean Pielemeier, CEO is reviewed by the Personnel Committee of the board of directors and is acted upon by the board of directors per committee recommendation. Compensation for officers is determined by Dean Pielemeier, CEO. Documents are not made available to the public."
|
||||||
|
}
|
||||||
|
$20
|
||||||
|
extraction_timestamp
|
||||||
|
$26
|
||||||
|
2025-11-09T16:12:46.318554
|
||||||
|
*2
|
||||||
|
$3
|
||||||
|
DEL
|
||||||
|
$56
|
||||||
|
:app.models.dataroom.DataRoom:01K9J2R5ZGS96G60P0G80W248Z
|
||||||
|
*10
|
||||||
|
$4
|
||||||
|
HSET
|
||||||
|
$56
|
||||||
|
:app.models.dataroom.DataRoom:01K9MSH70T90HY2VB6DPJVQZFQ
|
||||||
|
$2
|
||||||
|
pk
|
||||||
|
$26
|
||||||
|
01K9MSH70T90HY2VB6DPJVQZFQ
|
||||||
|
$4
|
||||||
|
name
|
||||||
|
$9
|
||||||
|
NEW HEART
|
||||||
|
$10
|
||||||
|
collection
|
||||||
|
$9
|
||||||
|
new_heart
|
||||||
|
$7
|
||||||
|
storage
|
||||||
|
$9
|
||||||
|
new_heart
|
||||||
|
*10
|
||||||
|
$4
|
||||||
|
HSET
|
||||||
|
$56
|
||||||
|
:app.models.dataroom.DataRoom:01K9MSJ57MS3DZFBG5TQXBDD6W
|
||||||
|
$2
|
||||||
|
pk
|
||||||
|
$26
|
||||||
|
01K9MSJ57MS3DZFBG5TQXBDD6W
|
||||||
|
$4
|
||||||
|
name
|
||||||
|
$5
|
||||||
|
OHANA
|
||||||
|
$10
|
||||||
|
collection
|
||||||
|
$5
|
||||||
|
ohana
|
||||||
|
$7
|
||||||
|
storage
|
||||||
|
$5
|
||||||
|
ohana
|
||||||
|
*10
|
||||||
|
$4
|
||||||
|
HSET
|
||||||
|
$56
|
||||||
|
:app.models.dataroom.DataRoom:01K9MSJJTH48BR7KQ27PXB2C3S
|
||||||
|
$2
|
||||||
|
pk
|
||||||
|
$26
|
||||||
|
01K9MSJJTH48BR7KQ27PXB2C3S
|
||||||
|
$4
|
||||||
|
name
|
||||||
|
$5
|
||||||
|
SOSFS
|
||||||
|
$10
|
||||||
|
collection
|
||||||
|
$5
|
||||||
|
sosfs
|
||||||
|
$7
|
||||||
|
storage
|
||||||
|
$5
|
||||||
|
sosfs
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user