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