This commit is contained in:
丹尼尔
2026-03-12 19:35:06 +08:00
commit ad96272ab6
40 changed files with 2645 additions and 0 deletions

40
.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Virtual environments
venv/
.venv/
env/
ENV/
# Distribution / packaging
build/
dist/
*.egg-info/
# SQLite DB & data
data/
# Node / frontend
frontend/node_modules/
frontend/.next/
# Environment & secrets
.env
.env.*
# Editor / IDE
.vscode/
.idea/
# OS files
.DS_Store
Thumbs.db
# Python tooling caches
.pytest_cache/
.mypy_cache/
.ruff_cache/

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@
__all__ = []

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
__all__ = []

38
backend/app/db.py Normal file
View File

@@ -0,0 +1,38 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
class Base(DeclarativeBase):
"""Base class for all ORM models."""
def get_database_url() -> str:
"""
DATABASE_URL is configurable via env, default to local SQLite file.
Example: sqlite:///./data/ops_core.db
"""
return os.getenv("DATABASE_URL", "sqlite:///./data/ops_core.db")
DATABASE_URL = get_database_url()
# For SQLite, check_same_thread=False is required when used with FastAPI / threads.
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
"""
FastAPI dependency to provide a DB session.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()

48
backend/app/main.py Normal file
View File

@@ -0,0 +1,48 @@
import os
from pathlib import Path
from typing import List
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from backend.app.routers import customers, projects, finance
def create_app() -> FastAPI:
app = FastAPI(
title="Ops-Core",
description="Monolithic automation & business ops platform",
version="0.1.0",
)
# CORS
raw_origins = os.getenv("CORS_ORIGINS")
if raw_origins:
origins: List[str] = [o.strip() for o in raw_origins.split(",") if o.strip()]
else:
origins = ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routers
app.include_router(customers.router)
app.include_router(projects.router)
app.include_router(finance.router)
# Static data mount (for quotes, contracts, finance archives, etc.)
data_dir = Path("data")
data_dir.mkdir(parents=True, exist_ok=True)
app.mount("/data", StaticFiles(directory=str(data_dir)), name="data")
return app
app = create_app()

83
backend/app/models.py Normal file
View File

@@ -0,0 +1,83 @@
from datetime import datetime
from sqlalchemy import (
Column,
DateTime,
ForeignKey,
Integer,
Numeric,
String,
Text,
)
from sqlalchemy.orm import relationship, Mapped, mapped_column
from .db import Base
class Customer(Base):
__tablename__ = "customers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
contact_info: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)
projects: Mapped[list["Project"]] = relationship(
"Project", back_populates="customer", cascade="all, delete-orphan"
)
class Project(Base):
"""
Project Archive: stores original requirement text and AI-generated solution.
"""
__tablename__ = "projects"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
customer_id: Mapped[int] = mapped_column(
Integer, ForeignKey("customers.id", ondelete="CASCADE"), nullable=False, index=True
)
raw_requirement: Mapped[str] = mapped_column(Text, nullable=False)
ai_solution_md: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(50), default="draft", nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)
customer: Mapped[Customer] = relationship("Customer", back_populates="projects")
quotes: Mapped[list["Quote"]] = relationship(
"Quote", back_populates="project", cascade="all, delete-orphan"
)
class Quote(Base):
__tablename__ = "quotes"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
project_id: Mapped[int] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
total_amount: Mapped[Numeric] = mapped_column(Numeric(12, 2), nullable=False)
file_path: Mapped[str] = mapped_column(String(512), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)
project: Mapped[Project] = relationship("Project", back_populates="quotes")
class FinanceRecord(Base):
__tablename__ = "finance_records"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
month: Mapped[str] = mapped_column(String(7), nullable=False, index=True) # YYYY-MM
type: Mapped[str] = mapped_column(String(50), nullable=False) # invoice / bank_receipt / ...
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
file_path: Mapped[str] = mapped_column(String(512), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)

View File

@@ -0,0 +1 @@
__all__ = []

View File

@@ -0,0 +1,71 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from backend.app.db import get_db
from backend.app import models
from backend.app.schemas import (
CustomerCreate,
CustomerRead,
CustomerUpdate,
)
router = APIRouter(prefix="/customers", tags=["customers"])
@router.get("/", response_model=List[CustomerRead])
async def list_customers(db: Session = Depends(get_db)):
customers = db.query(models.Customer).order_by(models.Customer.created_at.desc()).all()
return customers
@router.post("/", response_model=CustomerRead, status_code=status.HTTP_201_CREATED)
async def create_customer(payload: CustomerCreate, db: Session = Depends(get_db)):
customer = models.Customer(
name=payload.name,
contact_info=payload.contact_info,
)
db.add(customer)
db.commit()
db.refresh(customer)
return customer
@router.get("/{customer_id}", response_model=CustomerRead)
async def get_customer(customer_id: int, db: Session = Depends(get_db)):
customer = db.query(models.Customer).get(customer_id)
if not customer:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found")
return customer
@router.put("/{customer_id}", response_model=CustomerRead)
async def update_customer(
customer_id: int, payload: CustomerUpdate, db: Session = Depends(get_db)
):
customer = db.query(models.Customer).get(customer_id)
if not customer:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found")
if payload.name is not None:
customer.name = payload.name
if payload.contact_info is not None:
customer.contact_info = payload.contact_info
db.commit()
db.refresh(customer)
return customer
@router.delete("/{customer_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_customer(customer_id: int, db: Session = Depends(get_db)):
customer = db.query(models.Customer).get(customer_id)
if not customer:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found")
db.delete(customer)
db.commit()
return None

View File

@@ -0,0 +1,37 @@
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from backend.app.schemas import FinanceSyncResponse, FinanceSyncResult
from backend.app.services.email_service import create_monthly_zip, sync_finance_emails
router = APIRouter(prefix="/finance", tags=["finance"])
@router.post("/sync", response_model=FinanceSyncResponse)
async def sync_finance():
try:
items_raw = await sync_finance_emails()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
items = [FinanceSyncResult(**item) for item in items_raw]
return FinanceSyncResponse(items=items)
@router.get("/download/{month}")
async def download_finance_month(month: str):
"""
Download a zipped archive for a given month (YYYY-MM).
"""
try:
zip_path = await create_monthly_zip(month)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return FileResponse(
path=zip_path,
media_type="application/zip",
filename=f"finance_{month}.zip",
)

View File

@@ -0,0 +1,256 @@
from pathlib import Path
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from backend.app import models
from backend.app.db import get_db
from backend.app.schemas import (
ContractGenerateRequest,
ContractGenerateResponse,
ProjectRead,
ProjectUpdate,
QuoteGenerateResponse,
RequirementAnalyzeRequest,
RequirementAnalyzeResponse,
)
from backend.app.services.ai_service import analyze_requirement
from backend.app.services.doc_service import (
generate_contract_word,
generate_quote_excel,
generate_quote_pdf_from_data,
)
router = APIRouter(prefix="/projects", tags=["projects"])
def _build_markdown_from_analysis(data: Dict[str, Any]) -> str:
"""
Convert structured AI analysis JSON into a human-editable Markdown document.
"""
lines: list[str] = []
lines.append("# 项目方案草稿")
lines.append("")
total_hours = data.get("total_estimated_hours")
total_amount = data.get("total_amount")
if total_hours is not None or total_amount is not None:
lines.append("## 概要")
if total_hours is not None:
lines.append(f"- 建议总工时:**{total_hours}**")
if total_amount is not None:
lines.append(f"- 建议总报价:**{total_amount}**")
lines.append("")
modules = data.get("modules") or []
if modules:
lines.append("## 功能模块与技术实现")
for idx, module in enumerate(modules, start=1):
name = module.get("name", f"模块 {idx}")
desc = module.get("description") or ""
tech = module.get("technical_approach") or ""
hours = module.get("estimated_hours")
unit_price = module.get("unit_price")
subtotal = module.get("subtotal")
lines.append(f"### {idx}. {name}")
if desc:
lines.append(desc)
lines.append("")
if tech:
lines.append("**技术实现思路:**")
lines.append(tech)
lines.append("")
if hours is not None or unit_price is not None or subtotal is not None:
lines.append("**工时与报价:**")
if hours is not None:
lines.append(f"- 预估工时:{hours}")
if unit_price is not None:
lines.append(f"- 单价:{unit_price}")
if subtotal is not None:
lines.append(f"- 小计:{subtotal}")
lines.append("")
notes = data.get("notes")
if notes:
lines.append("## 备注")
lines.append(notes)
lines.append("")
return "\n".join(lines)
@router.get("/", response_model=list[ProjectRead])
async def list_projects(db: Session = Depends(get_db)):
projects = (
db.query(models.Project)
.order_by(models.Project.created_at.desc())
.all()
)
return projects
@router.get("/{project_id}", response_model=ProjectRead)
async def get_project(project_id: int, db: Session = Depends(get_db)):
project = db.query(models.Project).get(project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return project
@router.patch("/{project_id}", response_model=ProjectRead)
async def update_project(
project_id: int,
payload: ProjectUpdate,
db: Session = Depends(get_db),
):
project = db.query(models.Project).get(project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
if payload.ai_solution_md is not None:
project.ai_solution_md = payload.ai_solution_md
if payload.status is not None:
project.status = payload.status
db.commit()
db.refresh(project)
return project
@router.post("/analyze", response_model=RequirementAnalyzeResponse)
async def analyze_project_requirement(
payload: RequirementAnalyzeRequest,
db: Session = Depends(get_db),
):
# Ensure customer exists
customer = db.query(models.Customer).get(payload.customer_id)
if not customer:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found")
analysis = await analyze_requirement(payload.raw_text)
ai_solution_md = _build_markdown_from_analysis(analysis)
project = models.Project(
customer_id=payload.customer_id,
raw_requirement=payload.raw_text,
ai_solution_md=ai_solution_md,
status="draft",
)
db.add(project)
db.commit()
db.refresh(project)
return RequirementAnalyzeResponse(
project_id=project.id,
ai_solution_md=ai_solution_md,
ai_solution_json=analysis,
)
@router.post("/{project_id}/generate_quote", response_model=QuoteGenerateResponse)
async def generate_project_quote(
project_id: int,
db: Session = Depends(get_db),
):
project = db.query(models.Project).get(project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
# Re-analyze to get fresh structured data (the UI will allow user to edit Markdown separately).
analysis = await analyze_requirement(project.raw_requirement)
# Paths
base_dir = Path("data/quotes")
base_dir.mkdir(parents=True, exist_ok=True)
excel_path = base_dir / f"quote_project_{project.id}.xlsx"
pdf_path = base_dir / f"quote_project_{project.id}.pdf"
template_path = Path("templates/quote_template.xlsx")
if not template_path.exists():
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Quote template file not found on server.",
)
await generate_quote_excel(analysis, str(template_path), str(excel_path))
await generate_quote_pdf_from_data(analysis, str(pdf_path))
total_amount = float(analysis.get("total_amount") or 0.0)
quote = models.Quote(
project_id=project.id,
total_amount=total_amount,
file_path=str(pdf_path),
)
db.add(quote)
db.commit()
db.refresh(quote)
return QuoteGenerateResponse(
quote_id=quote.id,
project_id=project.id,
total_amount=total_amount,
excel_path=str(excel_path),
pdf_path=str(pdf_path),
)
@router.post(
"/{project_id}/generate_contract",
response_model=ContractGenerateResponse,
)
async def generate_project_contract(
project_id: int,
payload: ContractGenerateRequest,
db: Session = Depends(get_db),
):
project = db.query(models.Project).get(project_id)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
customer = project.customer
if not customer:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Project has no associated customer"
)
# Use the latest quote for this project to determine total price.
latest_quote = (
db.query(models.Quote)
.filter(models.Quote.project_id == project.id)
.order_by(models.Quote.created_at.desc())
.first()
)
if not latest_quote:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No quote found for this project.",
)
contracts_dir = Path("data/contracts")
contracts_dir.mkdir(parents=True, exist_ok=True)
output_path = contracts_dir / f"contract_project_{project.id}.docx"
template_path = Path("templates/contract_template.docx")
if not template_path.exists():
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Contract template file not found on server.",
)
# Build placeholder mapping
mapping: Dict[str, str] = {
"{{CUSTOMER_NAME}}": customer.name,
"{{TOTAL_PRICE}}": str(latest_quote.total_amount),
"{{DELIVERY_DATE}}": payload.delivery_date,
}
# Allow arbitrary additional placeholders
for key, value in payload.extra_placeholders.items():
mapping[key] = value
await generate_contract_word(mapping, str(template_path), str(output_path))
return ContractGenerateResponse(project_id=project.id, contract_path=str(output_path))

88
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,88 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class CustomerBase(BaseModel):
name: str = Field(..., description="Customer name")
contact_info: Optional[str] = Field(None, description="Contact information")
class CustomerCreate(CustomerBase):
pass
class CustomerUpdate(BaseModel):
name: Optional[str] = None
contact_info: Optional[str] = None
class CustomerRead(CustomerBase):
id: int
created_at: datetime
class Config:
from_attributes = True
class ProjectRead(BaseModel):
id: int
customer_id: int
raw_requirement: str
ai_solution_md: Optional[str] = None
status: str
created_at: datetime
class Config:
from_attributes = True
class ProjectUpdate(BaseModel):
ai_solution_md: Optional[str] = None
status: Optional[str] = None
class RequirementAnalyzeRequest(BaseModel):
customer_id: int = Field(..., description="Related customer id")
raw_text: str = Field(..., description="Raw requirement text from chat or WeChat")
class RequirementAnalyzeResponse(BaseModel):
project_id: int
ai_solution_md: str
ai_solution_json: Dict[str, Any]
class QuoteGenerateResponse(BaseModel):
quote_id: int
project_id: int
total_amount: float
excel_path: str
pdf_path: str
class ContractGenerateRequest(BaseModel):
delivery_date: str = Field(..., description="Delivery date, e.g. 2026-03-31")
extra_placeholders: Dict[str, str] = Field(
default_factory=dict,
description="Additional placeholder mappings for the contract template",
)
class ContractGenerateResponse(BaseModel):
project_id: int
contract_path: str
class FinanceSyncResult(BaseModel):
id: int
month: str
type: str
file_name: str
file_path: str
class FinanceSyncResponse(BaseModel):
items: List[FinanceSyncResult]

View File

@@ -0,0 +1 @@
__all__ = []

View File

@@ -0,0 +1,108 @@
import json
import os
from typing import Any, Dict
from openai import AsyncOpenAI
_client: AsyncOpenAI | None = None
def get_ai_client() -> AsyncOpenAI:
"""
Create (or reuse) a singleton AsyncOpenAI client.
The client is configured via:
- AI_API_KEY / OPENAI_API_KEY
- AI_BASE_URL (optional, defaults to official OpenAI endpoint)
- AI_MODEL (optional, defaults to gpt-4.1-mini or a similar capable model)
"""
global _client
if _client is not None:
return _client
api_key = os.getenv("AI_API_KEY") or os.getenv("OPENAI_API_KEY")
if not api_key:
raise RuntimeError("AI_API_KEY or OPENAI_API_KEY must be set in environment.")
base_url = os.getenv("AI_BASE_URL") # can point to OpenAI, DeepSeek, Qwen, etc.
_client = AsyncOpenAI(
api_key=api_key,
base_url=base_url or None,
)
return _client
def _build_requirement_prompt(raw_text: str) -> str:
"""
Build a clear system/user prompt for requirement analysis.
The model must output valid JSON only.
"""
return (
"你是一名资深的系统架构师,请阅读以下来自客户的原始需求文本,"
"提炼出清晰的交付方案,并严格按照指定 JSON 结构输出。\n\n"
"【要求】\n"
"1. 按功能模块拆分需求。\n"
"2. 每个模块给出简要说明和技术实现思路。\n"
"3. 估算建议工时(以人天或人小时为单位,使用数字)。\n"
"4. 可以根据你的经验给出每个模块的单价与小计金额,并给出总金额,"
"方便后续生成报价单。\n\n"
"【返回格式】请只返回 JSON不要包含任何额外说明文字\n"
"{\n"
' "modules": [\n'
" {\n"
' "name": "模块名称",\n'
' "description": "模块说明(可以为 Markdown 格式)",\n'
' "technical_approach": "技术实现思路Markdown 格式)",\n'
' "estimated_hours": 16,\n'
' "unit_price": 800,\n'
' "subtotal": 12800\n'
" }\n"
" ],\n"
' "total_estimated_hours": 40,\n'
' "total_amount": 32000,\n'
' "notes": "整体方案备注可选Markdown 格式)"\n'
"}\n\n"
f"【客户原始需求】\n{raw_text}"
)
async def analyze_requirement(raw_text: str) -> Dict[str, Any]:
"""
Call the AI model to analyze customer requirements.
Returns a Python dict matching the JSON structure described
in `_build_requirement_prompt`.
"""
client = get_ai_client()
model = os.getenv("AI_MODEL", "gpt-4.1-mini")
prompt = _build_requirement_prompt(raw_text)
completion = await client.chat.completions.create(
model=model,
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"你是一名严谨的系统架构师,只能输出有效的 JSON不要输出任何解释文字。"
),
},
{
"role": "user",
"content": prompt,
},
],
temperature=0.2,
)
content = completion.choices[0].message.content or "{}"
try:
data: Dict[str, Any] = json.loads(content)
except json.JSONDecodeError as exc:
raise RuntimeError(f"AI 返回的内容不是合法 JSON{content}") from exc
return data

View File

@@ -0,0 +1,189 @@
import asyncio
from pathlib import Path
from typing import Any, Dict, List
from docx import Document
from openpyxl import load_workbook
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
async def generate_quote_excel(
project_data: Dict[str, Any],
template_path: str,
output_path: str,
) -> str:
"""
Generate an Excel quote based on a template and structured project data.
project_data is expected to have the following structure (from AI JSON):
{
"modules": [
{
"name": "...",
"description": "...",
"technical_approach": "...",
"estimated_hours": 16,
"unit_price": 800,
"subtotal": 12800
},
...
],
"total_estimated_hours": 40,
"total_amount": 32000,
"notes": "..."
}
"""
async def _work() -> str:
template = Path(template_path)
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
wb = load_workbook(template)
# Assume the first worksheet is used for the quote.
ws = wb.active
modules: List[Dict[str, Any]] = project_data.get("modules", [])
total_amount = project_data.get("total_amount")
total_hours = project_data.get("total_estimated_hours")
notes = project_data.get("notes")
# Example layout assumptions (adjust cell coordinates to match your template):
# - Starting row for line items: 10
# - Columns:
# A: index, B: module name, C: description,
# D: estimated hours, E: unit price, F: subtotal
start_row = 10
for idx, module in enumerate(modules, start=1):
row = start_row + idx - 1
ws[f"A{row}"] = idx
ws[f"B{row}"] = module.get("name")
ws[f"C{row}"] = module.get("description")
ws[f"D{row}"] = module.get("estimated_hours")
ws[f"E{row}"] = module.get("unit_price")
ws[f"F{row}"] = module.get("subtotal")
# Place total hours and amount in typical footer cells (adjust as needed).
if total_hours is not None:
ws["D5"] = total_hours # e.g., total hours
if total_amount is not None:
ws["F5"] = total_amount # e.g., total amount
if notes:
ws["B6"] = notes
wb.save(output)
return str(output)
return await asyncio.to_thread(_work)
def _replace_in_paragraphs(paragraphs, mapping: Dict[str, str]) -> None:
for paragraph in paragraphs:
for placeholder, value in mapping.items():
if placeholder in paragraph.text:
# Rebuild runs to preserve basic formatting as much as possible.
inline = paragraph.runs
text = paragraph.text.replace(placeholder, value)
# Clear existing runs
for i in range(len(inline) - 1, -1, -1):
paragraph.runs[i].clear()
paragraph.runs[i].text = ""
# Add a single run with replaced text
paragraph.add_run(text)
def _replace_in_tables(tables, mapping: Dict[str, str]) -> None:
for table in tables:
for row in table.rows:
for cell in row.cells:
_replace_in_paragraphs(cell.paragraphs, mapping)
async def generate_contract_word(
contract_data: Dict[str, str],
template_path: str,
output_path: str,
) -> str:
"""
Generate a contract Word document by replacing placeholders.
contract_data is a flat dict like:
{
"{{CUSTOMER_NAME}}": "张三",
"{{TOTAL_PRICE}}": "¥32,000",
"{{DELIVERY_DATE}}": "2026-03-31",
...
}
"""
async def _work() -> str:
template = Path(template_path)
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
doc = Document(str(template))
_replace_in_paragraphs(doc.paragraphs, contract_data)
_replace_in_tables(doc.tables, contract_data)
doc.save(str(output))
return str(output)
return await asyncio.to_thread(_work)
async def generate_quote_pdf_from_data(
project_data: Dict[str, Any],
output_pdf_path: str,
) -> str:
"""
Generate a simple PDF quote summary directly from structured data.
This does not render the Excel visually, but provides a clean PDF
that can be sent to customers.
"""
async def _work() -> str:
output = Path(output_pdf_path)
output.parent.mkdir(parents=True, exist_ok=True)
c = canvas.Canvas(str(output), pagesize=A4)
width, height = A4
y = height - 40
c.setFont("Helvetica-Bold", 14)
c.drawString(40, y, "报价单 Quote")
y -= 30
c.setFont("Helvetica", 10)
modules: List[Dict[str, Any]] = project_data.get("modules", [])
for idx, module in enumerate(modules, start=1):
name = module.get("name", "")
hours = module.get("estimated_hours", "")
subtotal = module.get("subtotal", "")
line = f"{idx}. {name} - 工时: {hours}, 小计: {subtotal}"
c.drawString(40, y, line)
y -= 16
if y < 80:
c.showPage()
y = height - 40
c.setFont("Helvetica", 10)
total_amount = project_data.get("total_amount")
total_hours = project_data.get("total_estimated_hours")
y -= 10
c.setFont("Helvetica-Bold", 11)
if total_hours is not None:
c.drawString(40, y, f"总工时 Total Hours: {total_hours}")
y -= 18
if total_amount is not None:
c.drawString(40, y, f"总金额 Total Amount: {total_amount}")
c.showPage()
c.save()
return str(output)
return await asyncio.to_thread(_work)

View File

@@ -0,0 +1,215 @@
import asyncio
import email
import imaplib
import os
from datetime import datetime
from email.header import decode_header
from pathlib import Path
from typing import Any, Dict, List, Tuple
from backend.app.db import SessionLocal
from backend.app.models import FinanceRecord
FINANCE_BASE_DIR = Path("data/finance")
def _decode_header_value(value: str | None) -> str:
if not value:
return ""
parts = decode_header(value)
decoded = ""
for text, enc in parts:
if isinstance(text, bytes):
decoded += text.decode(enc or "utf-8", errors="ignore")
else:
decoded += text
return decoded
def _classify_type(subject: str) -> str:
"""
Classify finance document type based on subject keywords.
"""
subject_lower = subject.lower()
if any(k in subject for k in ["发票", "invoice"]):
return "invoices"
if any(k in subject for k in ["流水", "bank", "对账单", "statement"]):
return "bank_records"
if any(k in subject for k in ["回执", "receipt"]):
return "receipts"
return "others"
def _ensure_month_dir(month_str: str, doc_type: str) -> Path:
month_dir = FINANCE_BASE_DIR / month_str / doc_type
month_dir.mkdir(parents=True, exist_ok=True)
return month_dir
def _parse_email_date(msg: email.message.Message) -> datetime:
date_tuple = email.utils.parsedate_tz(msg.get("Date"))
if date_tuple:
dt = datetime.fromtimestamp(email.utils.mktime_tz(date_tuple))
else:
dt = datetime.utcnow()
return dt
def _save_attachment(
msg: email.message.Message,
month_str: str,
doc_type: str,
) -> List[Tuple[str, str]]:
"""
Save PDF/image attachments and return list of (file_name, file_path).
"""
saved: List[Tuple[str, str]] = []
base_dir = _ensure_month_dir(month_str, doc_type)
for part in msg.walk():
content_disposition = part.get("Content-Disposition", "")
if "attachment" not in content_disposition:
continue
filename = part.get_filename()
filename = _decode_header_value(filename)
if not filename:
continue
content_type = part.get_content_type()
maintype = part.get_content_maintype()
# Accept pdf and common images
if maintype not in ("application", "image"):
continue
data = part.get_payload(decode=True)
if not data:
continue
file_path = base_dir / filename
# Ensure unique filename
counter = 1
while file_path.exists():
stem = file_path.stem
suffix = file_path.suffix
file_path = base_dir / f"{stem}_{counter}{suffix}"
counter += 1
with open(file_path, "wb") as f:
f.write(data)
saved.append((filename, str(file_path)))
return saved
async def sync_finance_emails() -> List[Dict[str, Any]]:
"""
Connect to IMAP, fetch unread finance-related emails, download attachments,
save to filesystem and record FinanceRecord entries.
"""
def _sync() -> List[Dict[str, Any]]:
host = os.getenv("IMAP_HOST")
user = os.getenv("IMAP_USER")
password = os.getenv("IMAP_PASSWORD")
port = int(os.getenv("IMAP_PORT", "993"))
mailbox = os.getenv("IMAP_MAILBOX", "INBOX")
if not all([host, user, password]):
raise RuntimeError("IMAP_HOST, IMAP_USER, IMAP_PASSWORD must be set.")
results: List[Dict[str, Any]] = []
with imaplib.IMAP4_SSL(host, port) as imap:
imap.login(user, password)
imap.select(mailbox)
# Search for UNSEEN emails with finance related keywords in subject.
# Note: IMAP SEARCH is limited; here we search UNSEEN first then filter in Python.
status, data = imap.search(None, "UNSEEN")
if status != "OK":
return results
id_list = data[0].split()
db = SessionLocal()
try:
for msg_id in id_list:
status, msg_data = imap.fetch(msg_id, "(RFC822)")
if status != "OK":
continue
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
subject = _decode_header_value(msg.get("Subject"))
doc_type = _classify_type(subject)
# Filter by keywords first
if doc_type == "others":
continue
dt = _parse_email_date(msg)
month_str = dt.strftime("%Y-%m")
saved_files = _save_attachment(msg, month_str, doc_type)
for file_name, file_path in saved_files:
record = FinanceRecord(
month=month_str,
type=doc_type,
file_name=file_name,
file_path=file_path,
)
# NOTE: created_at defaults at DB layer
db.add(record)
db.flush()
results.append(
{
"id": record.id,
"month": record.month,
"type": record.type,
"file_name": record.file_name,
"file_path": record.file_path,
}
)
# Mark email as seen and flagged to avoid re-processing
imap.store(msg_id, "+FLAGS", "\\Seen \\Flagged")
db.commit()
finally:
db.close()
return results
return await asyncio.to_thread(_sync)
async def create_monthly_zip(month_str: str) -> str:
"""
Zip the finance folder for a given month (YYYY-MM) and return the zip path.
"""
import zipfile
def _zip() -> str:
month_dir = FINANCE_BASE_DIR / month_str
if not month_dir.exists():
raise FileNotFoundError(f"Finance directory for {month_str} not found.")
FINANCE_BASE_DIR.mkdir(parents=True, exist_ok=True)
zip_path = FINANCE_BASE_DIR / f"{month_str}.zip"
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for root, _, files in os.walk(month_dir):
for file in files:
full_path = Path(root) / file
rel_path = full_path.relative_to(FINANCE_BASE_DIR)
zf.write(full_path, arcname=rel_path)
return str(zip_path)
return await asyncio.to_thread(_zip)

View File

@@ -0,0 +1,16 @@
import { AppSidebar } from "@/components/app-sidebar";
import { Toaster } from "sonner";
export default function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-screen overflow-hidden">
<AppSidebar />
<main className="flex-1 overflow-auto bg-background">{children}</main>
<Toaster position="top-right" richColors closeButton />
</div>
);
}

View File

@@ -0,0 +1,19 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function HomePage() {
return (
<div className="flex min-h-full flex-col items-center justify-center p-8">
<h1 className="text-2xl font-semibold text-foreground">Ops-Core</h1>
<p className="mt-2 text-muted-foreground"></p>
<div className="mt-6 flex gap-3">
<Button asChild>
<Link href="/workspace"></Link>
</Button>
<Button variant="outline" asChild>
<Link href="/finance"></Link>
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,271 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import ReactMarkdown from "react-markdown";
import { Wand2, Save, FileSpreadsheet, FileDown, Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
customersApi,
projectsApi,
downloadFile,
downloadFileAsBlob,
type CustomerRead,
type QuoteGenerateResponse,
} from "@/lib/api/client";
export default function WorkspacePage() {
const [customers, setCustomers] = useState<CustomerRead[]>([]);
const [customerId, setCustomerId] = useState<string>("");
const [rawText, setRawText] = useState("");
const [solutionMd, setSolutionMd] = useState("");
const [projectId, setProjectId] = useState<number | null>(null);
const [lastQuote, setLastQuote] = useState<QuoteGenerateResponse | null>(null);
const [analyzing, setAnalyzing] = useState(false);
const [saving, setSaving] = useState(false);
const [generatingQuote, setGeneratingQuote] = useState(false);
const loadCustomers = useCallback(async () => {
try {
const list = await customersApi.list();
setCustomers(list);
if (list.length > 0 && !customerId) setCustomerId(String(list[0].id));
} catch (e) {
toast.error("加载客户列表失败");
}
}, [customerId]);
useEffect(() => {
loadCustomers();
}, [loadCustomers]);
const handleAnalyze = async () => {
if (!customerId || !rawText.trim()) {
toast.error("请选择客户并输入原始需求");
return;
}
setAnalyzing(true);
try {
const res = await projectsApi.analyze({
customer_id: Number(customerId),
raw_text: rawText.trim(),
});
setSolutionMd(res.ai_solution_md);
setProjectId(res.project_id);
setLastQuote(null);
toast.success("方案已生成,可在右侧编辑");
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "AI 解析失败";
toast.error(msg);
} finally {
setAnalyzing(false);
}
};
const handleSaveToArchive = async () => {
if (projectId == null) {
toast.error("请先进行 AI 解析");
return;
}
setSaving(true);
try {
await projectsApi.update(projectId, { ai_solution_md: solutionMd });
toast.success("已保存到项目档案");
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "保存失败";
toast.error(msg);
} finally {
setSaving(false);
}
};
const handleDraftQuote = async () => {
if (projectId == null) {
toast.error("请先进行 AI 解析并保存");
return;
}
setGeneratingQuote(true);
try {
const res = await projectsApi.generateQuote(projectId);
setLastQuote(res);
toast.success("报价单已生成");
downloadFile(res.excel_path, `quote_project_${projectId}.xlsx`);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "生成报价失败";
toast.error(msg);
} finally {
setGeneratingQuote(false);
}
};
const handleExportPdf = async () => {
if (lastQuote?.pdf_path) {
downloadFile(lastQuote.pdf_path, `quote_project_${projectId}.pdf`);
toast.success("PDF 已下载");
return;
}
if (projectId == null) {
toast.error("请先进行 AI 解析");
return;
}
setGeneratingQuote(true);
try {
const res = await projectsApi.generateQuote(projectId);
setLastQuote(res);
await downloadFileAsBlob(res.pdf_path, `quote_project_${projectId}.pdf`);
toast.success("PDF 已下载");
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "生成 PDF 失败";
toast.error(msg);
} finally {
setGeneratingQuote(false);
}
};
return (
<div className="flex h-full flex-col">
<div className="flex flex-1 min-h-0">
{/* Left Panel — 40% */}
<div className="flex w-[40%] flex-col border-r">
<Card className="rounded-none border-0 border-b h-full flex flex-col">
<CardHeader className="py-3">
<CardTitle className="text-base flex items-center justify-between">
<Button
size="sm"
onClick={handleAnalyze}
disabled={analyzing || !rawText.trim() || !customerId}
>
{analyzing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wand2 className="h-4 w-4" />
)}
<span className="ml-1.5">AI </span>
</Button>
</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col gap-2 min-h-0 pt-0">
<div className="space-y-1.5">
<Label></Label>
<Select value={customerId} onValueChange={setCustomerId}>
<SelectTrigger>
<SelectValue placeholder="选择客户" />
</SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 flex flex-col min-h-0">
<Label>/</Label>
<textarea
className="mt-1.5 flex-1 min-h-[200px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 resize-none"
placeholder="粘贴或输入客户原始需求..."
value={rawText}
onChange={(e) => setRawText(e.target.value)}
/>
</div>
</CardContent>
</Card>
</div>
{/* Right Panel — 60% */}
<div className="flex flex-1 flex-col min-w-0">
<Card className="rounded-none border-0 h-full flex flex-col">
<CardHeader className="py-3">
<CardTitle className="text-base">稿</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<Tabs defaultValue="edit" className="flex-1 flex flex-col min-h-0">
<TabsList>
<TabsTrigger value="edit"></TabsTrigger>
<TabsTrigger value="preview"></TabsTrigger>
</TabsList>
<TabsContent value="edit" className="flex-1 min-h-0 mt-2">
<textarea
className="h-full min-h-[320px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 resize-none"
placeholder="AI 解析后将在此显示 Markdown可直接修改..."
value={solutionMd}
onChange={(e) => setSolutionMd(e.target.value)}
/>
</TabsContent>
<TabsContent value="preview" className="flex-1 min-h-0 mt-2 overflow-auto">
<div className="prose prose-sm dark:prose-invert max-w-none p-2">
{solutionMd ? (
<ReactMarkdown>{solutionMd}</ReactMarkdown>
) : (
<p className="text-muted-foreground"></p>
)}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
</div>
{/* Floating Action Bar */}
<div className="flex items-center gap-3 border-t bg-card px-4 py-3 shadow-[0_-2px 10px rgba(0,0,0,0.05)]">
<Button
variant="outline"
size="sm"
onClick={handleSaveToArchive}
disabled={saving || projectId == null}
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
<span className="ml-1.5"></span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDraftQuote}
disabled={generatingQuote || projectId == null}
>
{generatingQuote ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileSpreadsheet className="h-4 w-4" />
)}
<span className="ml-1.5"> (Excel)</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExportPdf}
disabled={generatingQuote || projectId == null}
>
{generatingQuote ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileDown className="h-4 w-4" />
)}
<span className="ml-1.5"> PDF</span>
</Button>
{projectId != null && (
<span className="text-xs text-muted-foreground ml-2">
#{projectId}
</span>
)}
</div>
</div>
);
}

59
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

19
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Ops-Core | 自动化办公与业务中台",
description: "Monolithic automation & business ops platform",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body className="antialiased min-h-screen">{children}</body>
</html>
);
}

21
frontend/components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -0,0 +1,81 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
FileText,
FolderArchive,
Settings,
Building2,
Globe,
PiggyBank,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { HistoricalReferences } from "@/components/historical-references";
const QUICK_LINKS = [
{ label: "国家税务总局门户", href: "https://www.chinatax.gov.cn", icon: Building2 },
{ label: "电子税务局", href: "https://etax.chinatax.gov.cn", icon: Globe },
{ label: "公积金管理中心", href: "https://www.12329.com.cn", icon: PiggyBank },
];
export function AppSidebar() {
const pathname = usePathname();
const nav = [
{ href: "/workspace", label: "需求与方案", icon: FileText },
{ href: "/finance", label: "财务归档", icon: FolderArchive },
{ href: "/settings", label: "设置", icon: Settings },
];
return (
<aside className="flex w-56 flex-col border-r bg-card text-card-foreground">
<div className="p-4">
<Link href="/" className="font-semibold text-foreground">
Ops-Core
</Link>
<p className="text-xs text-muted-foreground mt-0.5"></p>
</div>
<Separator />
<nav className="flex flex-1 flex-col gap-1 p-2">
{nav.map((item) => (
<Link key={item.href} href={item.href}>
<Button
variant={pathname === item.href ? "secondary" : "ghost"}
size="sm"
className="w-full justify-start"
>
<item.icon className="mr-2 h-4 w-4" />
{item.label}
</Button>
</Link>
))}
</nav>
<Separator />
<div className="p-2">
<p className="text-xs font-medium text-muted-foreground px-2 mb-2">
</p>
<div className="flex flex-col gap-1">
{QUICK_LINKS.map((link) => (
<a
key={link.href}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<link.icon className="h-4 w-4 shrink-0" />
{link.label}
</a>
))}
</div>
</div>
{pathname === "/workspace" && <HistoricalReferences />}
</aside>
);
}

View File

@@ -0,0 +1,111 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { listProjects, type ProjectRead } from "@/lib/api/client";
import { Copy, Search, FileText } from "lucide-react";
import { toast } from "sonner";
export function HistoricalReferences() {
const [projects, setProjects] = useState<ProjectRead[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await listProjects();
setProjects(data);
} catch (e) {
toast.error("加载历史项目失败");
setProjects([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const filtered = search.trim()
? projects.filter(
(p) =>
p.raw_requirement.toLowerCase().includes(search.toLowerCase()) ||
(p.ai_solution_md || "").toLowerCase().includes(search.toLowerCase())
)
: projects.slice(0, 10);
const copySnippet = (text: string, label: string) => {
if (!text) return;
navigator.clipboard.writeText(text);
toast.success(`已复制 ${label}`);
};
return (
<div className="border-t p-2">
<p className="text-xs font-medium text-muted-foreground px-2 mb-2 flex items-center gap-1">
<FileText className="h-3.5 w-3.5" />
</p>
<div className="relative mb-2">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索项目..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 pl-7 text-xs"
/>
</div>
<div className="max-h-48 overflow-y-auto space-y-1">
{loading ? (
<p className="text-xs text-muted-foreground px-2">...</p>
) : filtered.length === 0 ? (
<p className="text-xs text-muted-foreground px-2"></p>
) : (
filtered.map((p) => (
<div
key={p.id}
className={cn(
"rounded border bg-background/50 p-2 text-xs space-y-1"
)}
>
<p className="font-medium text-foreground line-clamp-1">
#{p.id}
</p>
<p className="text-muted-foreground line-clamp-2">
{p.raw_requirement.slice(0, 80)}
</p>
<div className="flex gap-1 pt-1">
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={() =>
copySnippet(p.raw_requirement, "原始需求")
}
>
<Copy className="h-3 w-3 mr-0.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs"
onClick={() =>
copySnippet(p.ai_solution_md || "", "方案")
}
>
<Copy className="h-3 w-3 mr-0.5" />
</Button>
</div>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,78 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,25 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,157 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
};

View File

@@ -0,0 +1,30 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@@ -0,0 +1,54 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

247
frontend/lib/api/client.ts Normal file
View File

@@ -0,0 +1,247 @@
/**
* API client for the FastAPI backend (Ops-Core).
* Base URL is read from NEXT_PUBLIC_API_BASE (default http://localhost:8000).
*/
const getBase = (): string => {
if (typeof window !== "undefined") {
return process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
}
return process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
};
export const apiBase = getBase;
// --------------- Types (mirror FastAPI schemas) ---------------
export interface CustomerRead {
id: number;
name: string;
contact_info: string | null;
created_at: string;
}
export interface CustomerCreate {
name: string;
contact_info?: string | null;
}
export interface CustomerUpdate {
name?: string;
contact_info?: string | null;
}
export interface RequirementAnalyzeRequest {
customer_id: number;
raw_text: string;
}
export interface RequirementAnalyzeResponse {
project_id: number;
ai_solution_md: string;
ai_solution_json: Record<string, unknown>;
}
export interface QuoteGenerateResponse {
quote_id: number;
project_id: number;
total_amount: number;
excel_path: string;
pdf_path: string;
}
export interface ContractGenerateRequest {
delivery_date: string;
extra_placeholders?: Record<string, string>;
}
export interface ContractGenerateResponse {
project_id: number;
contract_path: string;
}
export interface FinanceSyncResult {
id: number;
month: string;
type: string;
file_name: string;
file_path: string;
}
export interface FinanceSyncResponse {
items: FinanceSyncResult[];
}
export interface ProjectRead {
id: number;
customer_id: number;
raw_requirement: string;
ai_solution_md: string | null;
status: string;
created_at: string;
customer?: CustomerRead;
quotes?: { id: number; total_amount: string; file_path: string }[];
}
export interface ProjectUpdate {
ai_solution_md?: string | null;
status?: string;
}
// --------------- Request helper ---------------
async function request<T>(
path: string,
options: RequestInit & { params?: Record<string, string> } = {}
): Promise<T> {
const { params, ...init } = options;
const base = apiBase();
let url = `${base}${path}`;
if (params && Object.keys(params).length > 0) {
const search = new URLSearchParams(params).toString();
url += (path.includes("?") ? "&" : "?") + search;
}
const res = await fetch(url, {
...init,
headers: {
"Content-Type": "application/json",
...init.headers,
},
});
if (!res.ok) {
const text = await res.text();
let detail = text;
try {
const j = JSON.parse(text);
detail = j.detail ?? text;
} catch {
// keep text
}
throw new Error(detail);
}
const contentType = res.headers.get("content-type");
if (contentType?.includes("application/json")) {
return res.json() as Promise<T>;
}
return undefined as T;
}
/** Download a file from the backend and return a Blob (for Excel/PDF/Zip). */
export async function downloadBlob(path: string): Promise<Blob> {
const base = apiBase();
const url = path.startsWith("http") ? path : `${base}${path.startsWith("/") ? "" : "/"}${path}`;
const res = await fetch(url, { credentials: "include" });
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
return res.blob();
}
/**
* Trigger download in the browser (Excel, PDF, or Zip).
* path: backend-returned path e.g. "data/quotes/quote_project_1.xlsx" -> we request /data/quotes/quote_project_1.xlsx
*/
export function downloadFile(path: string, filename: string): void {
const base = apiBase();
const normalized = path.startsWith("/") ? path : `/${path}`;
const url = `${base}${normalized}`;
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.rel = "noopener noreferrer";
a.target = "_blank";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
/** Same as downloadFile but using fetch + Blob for consistent CORS and filename control. */
export async function downloadFileAsBlob(
path: string,
filename: string,
mime?: string
): Promise<void> {
const blob = await downloadBlob(path);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// --------------- Customers ---------------
export const customersApi = {
list: () => request<CustomerRead[]>("/customers/"),
get: (id: number) => request<CustomerRead>(`/customers/${id}`),
create: (body: CustomerCreate) =>
request<CustomerRead>("/customers/", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: number, body: CustomerUpdate) =>
request<CustomerRead>(`/customers/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
delete: (id: number) =>
request<void>(`/customers/${id}`, { method: "DELETE" }),
};
// --------------- Projects ---------------
export const projectsApi = {
analyze: (body: RequirementAnalyzeRequest) =>
request<RequirementAnalyzeResponse>("/projects/analyze", {
method: "POST",
body: JSON.stringify(body),
}),
update: (projectId: number, body: ProjectUpdate) =>
request<ProjectRead>(`/projects/${projectId}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
generateQuote: (projectId: number) =>
request<QuoteGenerateResponse>(`/projects/${projectId}/generate_quote`, {
method: "POST",
}),
generateContract: (projectId: number, body: ContractGenerateRequest) =>
request<ContractGenerateResponse>(
`/projects/${projectId}/generate_contract`,
{
method: "POST",
body: JSON.stringify(body),
}
),
};
export async function listProjects(): Promise<ProjectRead[]> {
return request<ProjectRead[]>("/projects/");
}
export async function getProject(projectId: number): Promise<ProjectRead> {
return request<ProjectRead>(`/projects/${projectId}`);
}
// --------------- Finance ---------------
export const financeApi = {
sync: () =>
request<FinanceSyncResponse>("/finance/sync", { method: "POST" }),
/** Returns the URL to download the zip (or use downloadFile with the path). */
getDownloadUrl: (month: string) => `${apiBase()}/finance/download/${month}`,
/** Download monthly zip as blob and trigger save. */
downloadMonth: async (month: string): Promise<void> => {
const path = `/finance/download/${month}`;
await downloadFileAsBlob(path, `finance_${month}.zip`, "application/zip");
},
};

6
frontend/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

5
frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

10
frontend/next.config.mjs Normal file
View File

@@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// Proxy API to FastAPI in dev if needed (optional; frontend can call API_BASE directly)
async rewrites() {
return [];
},
};
export default nextConfig;

46
frontend/package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "ops-core-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.18",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.454.0",
"tailwind-merge": "^2.5.4",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.3",
"react-markdown": "^9.0.1",
"sonner": "^1.5.0"
},
"devDependencies": {
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-next": "14.2.18",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"tailwindcss-animate": "^1.0.7",
"@tailwindcss/typography": "^0.5.15"
}
}

View File

@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

View File

@@ -0,0 +1,57 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
};
export default config;

20
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

19
requirements.txt Normal file
View File

@@ -0,0 +1,19 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy==2.0.36
alembic==1.14.0
aiosqlite==0.20.0
pydantic==2.9.2
pydantic-settings==2.6.1
python-dotenv==1.0.1
streamlit==1.40.0
openai==1.57.0
httpx==0.27.2
openpyxl==3.1.5
reportlab==4.2.5
python-docx==1.1.2
python-multipart==0.0.12

32
scripts/init_db.py Normal file
View File

@@ -0,0 +1,32 @@
"""
Simple database initialization script.
Usage:
python scripts/init_db.py
"""
import os
from pathlib import Path
from backend.app.db import Base, engine
from backend.app import models # noqa: F401 - ensure models are imported
def ensure_data_dir():
db_url = os.getenv("DATABASE_URL", "sqlite:///./data/ops_core.db")
if db_url.startswith("sqlite:///"):
relative_path = db_url.replace("sqlite:///", "")
db_path = Path(relative_path).resolve()
db_dir = db_path.parent
db_dir.mkdir(parents=True, exist_ok=True)
def init_db():
ensure_data_dir()
Base.metadata.create_all(bind=engine)
print("Database initialized.")
if __name__ == "__main__":
init_db()