fix:bug
This commit is contained in:
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal 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
1
backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__all__ = []
|
||||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__all__ = []
|
||||||
38
backend/app/db.py
Normal file
38
backend/app/db.py
Normal 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
48
backend/app/main.py
Normal 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
83
backend/app/models.py
Normal 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
|
||||||
|
)
|
||||||
|
|
||||||
1
backend/app/routers/__init__.py
Normal file
1
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__all__ = []
|
||||||
71
backend/app/routers/customers.py
Normal file
71
backend/app/routers/customers.py
Normal 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
|
||||||
|
|
||||||
37
backend/app/routers/finance.py
Normal file
37
backend/app/routers/finance.py
Normal 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",
|
||||||
|
)
|
||||||
|
|
||||||
256
backend/app/routers/projects.py
Normal file
256
backend/app/routers/projects.py
Normal 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
88
backend/app/schemas.py
Normal 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]
|
||||||
|
|
||||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__all__ = []
|
||||||
108
backend/app/services/ai_service.py
Normal file
108
backend/app/services/ai_service.py
Normal 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
|
||||||
|
|
||||||
189
backend/app/services/doc_service.py
Normal file
189
backend/app/services/doc_service.py
Normal 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)
|
||||||
|
|
||||||
215
backend/app/services/email_service.py
Normal file
215
backend/app/services/email_service.py
Normal 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)
|
||||||
|
|
||||||
16
frontend/app/(main)/layout.tsx
Normal file
16
frontend/app/(main)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/app/(main)/page.tsx
Normal file
19
frontend/app/(main)/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
frontend/app/(main)/workspace/page.tsx
Normal file
271
frontend/app/(main)/workspace/page.tsx
Normal 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
59
frontend/app/globals.css
Normal 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
19
frontend/app/layout.tsx
Normal 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
21
frontend/components.json
Normal 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"
|
||||||
|
}
|
||||||
81
frontend/components/app-sidebar.tsx
Normal file
81
frontend/components/app-sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
frontend/components/historical-references.tsx
Normal file
111
frontend/components/historical-references.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
frontend/components/ui/button.tsx
Normal file
52
frontend/components/ui/button.tsx
Normal 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 };
|
||||||
78
frontend/components/ui/card.tsx
Normal file
78
frontend/components/ui/card.tsx
Normal 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 };
|
||||||
24
frontend/components/ui/input.tsx
Normal file
24
frontend/components/ui/input.tsx
Normal 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 };
|
||||||
25
frontend/components/ui/label.tsx
Normal file
25
frontend/components/ui/label.tsx
Normal 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 };
|
||||||
157
frontend/components/ui/select.tsx
Normal file
157
frontend/components/ui/select.tsx
Normal 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,
|
||||||
|
};
|
||||||
30
frontend/components/ui/separator.tsx
Normal file
30
frontend/components/ui/separator.tsx
Normal 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 };
|
||||||
54
frontend/components/ui/tabs.tsx
Normal file
54
frontend/components/ui/tabs.tsx
Normal 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
247
frontend/lib/api/client.ts
Normal 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
6
frontend/lib/utils.ts
Normal 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
5
frontend/next-env.d.ts
vendored
Normal 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
10
frontend/next.config.mjs
Normal 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
46
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
frontend/postcss.config.mjs
Normal file
9
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
57
frontend/tailwind.config.ts
Normal file
57
frontend/tailwind.config.ts
Normal 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
20
frontend/tsconfig.json
Normal 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
19
requirements.txt
Normal 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
32
scripts/init_db.py
Normal 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()
|
||||||
|
|
||||||
Reference in New Issue
Block a user