Initial commit - GMAL Scope Builder
Dockerized web app (FastAPI + React + PostgreSQL) for scoping client ratecards against the GMAL master asset database. Features: - GMAL data ingestion from Excel (390 assets, 120 roles, 5 model types) - AI-powered document parsing and asset extraction (Claude Opus 4.6) - AI matching engine with parallel batching, confidence scoring, caveats - Ratecard builder with hours x volume calculation - Excel and PDF export - GMAL browser and inline editor - AI cost tracking per project (persisted to DB) - Debug panel for AI call inspection - Dark theme UI with gold (#FFC407) accent Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
e18976fdb2
59 changed files with 6627 additions and 0 deletions
2
.env.example
Normal file
2
.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
POSTGRES_PASSWORD=scope_pass_2024
|
||||
ANTHROPIC_API_KEY=your-api-key-here
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
data/*.xlsx
|
||||
*.xlsx
|
||||
.DS_Store
|
||||
16
backend/Dockerfile
Normal file
16
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN chmod +x start.sh
|
||||
|
||||
CMD ["bash", "start.sh"]
|
||||
36
backend/alembic.ini
Normal file
36
backend/alembic.ini
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
[alembic]
|
||||
script_location = alembic
|
||||
sqlalchemy.url = postgresql://scope_user:scope_pass_2024@db:5432/scope_builder
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
45
backend/alembic/env.py
Normal file
45
backend/alembic/env.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Override URL from environment if available
|
||||
db_url = os.getenv("DATABASE_URL_SYNC")
|
||||
if db_url:
|
||||
config.set_main_option("sqlalchemy.url", db_url)
|
||||
|
||||
from app.database import Base
|
||||
from app.models import * # noqa: F401,F403
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
23
backend/alembic/script.py.mako
Normal file
23
backend/alembic/script.py.mako
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
181
backend/alembic/versions/001_initial_schema.py
Normal file
181
backend/alembic/versions/001_initial_schema.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""Initial schema
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2026-03-27
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '001'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
model_type_enum = sa.Enum(
|
||||
'current_oplus', 'ai_oplus', 'current_local', 'ai_local', 'asset_factory',
|
||||
name='modeltype', create_type=False,
|
||||
)
|
||||
project_status_enum = sa.Enum(
|
||||
'draft', 'parsing', 'matching', 'review', 'building', 'finalized',
|
||||
name='projectstatus', create_type=False,
|
||||
)
|
||||
match_confidence_enum = sa.Enum(
|
||||
'exact', 'close', 'multiple', 'none',
|
||||
name='matchconfidence', create_type=False,
|
||||
)
|
||||
|
||||
# Create enum types explicitly
|
||||
op.execute("DO $$ BEGIN CREATE TYPE modeltype AS ENUM ('current_oplus', 'ai_oplus', 'current_local', 'ai_local', 'asset_factory'); EXCEPTION WHEN duplicate_object THEN NULL; END $$")
|
||||
op.execute("DO $$ BEGIN CREATE TYPE projectstatus AS ENUM ('draft', 'parsing', 'matching', 'review', 'building', 'finalized'); EXCEPTION WHEN duplicate_object THEN NULL; END $$")
|
||||
op.execute("DO $$ BEGIN CREATE TYPE matchconfidence AS ENUM ('exact', 'close', 'multiple', 'none'); EXCEPTION WHEN duplicate_object THEN NULL; END $$")
|
||||
|
||||
# gmal_assets
|
||||
op.create_table(
|
||||
'gmal_assets',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('gmal_id', sa.String(20), nullable=False, unique=True),
|
||||
sa.Column('swop_asset_id', sa.String(50)),
|
||||
sa.Column('region', sa.String(20)),
|
||||
sa.Column('category', sa.String(100)),
|
||||
sa.Column('sub_category', sa.String(100), index=True),
|
||||
sa.Column('sub_category_description', sa.Text()),
|
||||
sa.Column('asset_name', sa.String(255)),
|
||||
sa.Column('complexity_level', sa.Integer()),
|
||||
sa.Column('complexity_name', sa.String(20)),
|
||||
sa.Column('unique_name', sa.String(255)),
|
||||
sa.Column('asset_description', sa.Text()),
|
||||
sa.Column('complexity_description', sa.Text()),
|
||||
sa.Column('caveats', sa.Text()),
|
||||
sa.Column('ai_enhanced_description', sa.Text()),
|
||||
sa.Column('master_adapt', sa.String(20)),
|
||||
sa.Column('ai_efficiency_pct', sa.Numeric(5, 2)),
|
||||
sa.Column('has_hour_routes', sa.Boolean(), default=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index('ix_gmal_assets_gmal_id', 'gmal_assets', ['gmal_id'])
|
||||
|
||||
# roles
|
||||
op.create_table(
|
||||
'roles',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('discipline', sa.String(100), nullable=False, index=True),
|
||||
sa.Column('role_title', sa.String(200), nullable=False),
|
||||
sa.Column('entity', sa.String(100)),
|
||||
sa.Column('resource_location', sa.String(50)),
|
||||
sa.Column('unique_name', sa.String(255)),
|
||||
sa.Column('sort_order', sa.Integer()),
|
||||
sa.Column('is_programme_role', sa.Boolean(), default=False),
|
||||
sa.UniqueConstraint('role_title', 'entity', name='uq_role_title_entity'),
|
||||
)
|
||||
|
||||
# gmal_hours
|
||||
op.create_table(
|
||||
'gmal_hours',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('gmal_asset_id', sa.Integer(), sa.ForeignKey('gmal_assets.id'), nullable=False),
|
||||
sa.Column('role_id', sa.Integer(), sa.ForeignKey('roles.id'), nullable=False),
|
||||
sa.Column('model_type', model_type_enum, nullable=False),
|
||||
sa.Column('hours', sa.Numeric(10, 2), nullable=False),
|
||||
sa.UniqueConstraint('gmal_asset_id', 'role_id', 'model_type', name='uq_gmal_role_model'),
|
||||
)
|
||||
op.create_index('idx_gmal_hours_asset', 'gmal_hours', ['gmal_asset_id'])
|
||||
op.create_index('idx_gmal_hours_model', 'gmal_hours', ['model_type'])
|
||||
|
||||
# gmal_service_lines
|
||||
op.create_table(
|
||||
'gmal_service_lines',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('number', sa.String(20)),
|
||||
sa.Column('name', sa.String(255)),
|
||||
sa.Column('type', sa.String(50)),
|
||||
sa.Column('gmal_id', sa.String(20), index=True),
|
||||
)
|
||||
|
||||
# role_level_mappings
|
||||
op.create_table(
|
||||
'role_level_mappings',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('role_name', sa.String(200)),
|
||||
sa.Column('number', sa.String(20)),
|
||||
sa.Column('level_name', sa.String(200)),
|
||||
sa.Column('type', sa.String(50)),
|
||||
)
|
||||
|
||||
# projects
|
||||
op.create_table(
|
||||
'projects',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('client_name', sa.String(255)),
|
||||
sa.Column('description', sa.Text()),
|
||||
sa.Column('model_type', model_type_enum, default='current_oplus'),
|
||||
sa.Column('status', project_status_enum, default='draft'),
|
||||
sa.Column('source_filename', sa.String(255)),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# client_assets
|
||||
op.create_table(
|
||||
'client_assets',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('project_id', sa.Integer(), sa.ForeignKey('projects.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('raw_name', sa.String(500)),
|
||||
sa.Column('raw_description', sa.Text()),
|
||||
sa.Column('volume', sa.Integer(), default=1),
|
||||
sa.Column('sort_order', sa.Integer()),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# matches
|
||||
op.create_table(
|
||||
'matches',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('client_asset_id', sa.Integer(), sa.ForeignKey('client_assets.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('gmal_asset_id', sa.Integer(), sa.ForeignKey('gmal_assets.id'), nullable=False),
|
||||
sa.Column('confidence', match_confidence_enum, nullable=False),
|
||||
sa.Column('confidence_score', sa.Numeric(3, 2)),
|
||||
sa.Column('ai_reasoning', sa.Text()),
|
||||
sa.Column('caveat_text', sa.Text()),
|
||||
sa.Column('is_selected', sa.Boolean(), default=False),
|
||||
sa.Column('rank', sa.Integer(), default=1),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index('idx_matches_client_asset', 'matches', ['client_asset_id'])
|
||||
|
||||
# ratecard_lines
|
||||
op.create_table(
|
||||
'ratecard_lines',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('project_id', sa.Integer(), sa.ForeignKey('projects.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('client_asset_id', sa.Integer(), sa.ForeignKey('client_assets.id'), nullable=False),
|
||||
sa.Column('gmal_asset_id', sa.Integer(), sa.ForeignKey('gmal_assets.id'), nullable=False),
|
||||
sa.Column('role_id', sa.Integer(), sa.ForeignKey('roles.id'), nullable=False),
|
||||
sa.Column('base_hours', sa.Numeric(10, 2)),
|
||||
sa.Column('volume', sa.Integer(), default=1),
|
||||
sa.Column('total_hours', sa.Numeric(10, 2)),
|
||||
sa.Column('manual_override', sa.Numeric(10, 2)),
|
||||
sa.Column('notes', sa.Text()),
|
||||
)
|
||||
op.create_index('idx_ratecard_project', 'ratecard_lines', ['project_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('ratecard_lines')
|
||||
op.drop_table('matches')
|
||||
op.drop_table('client_assets')
|
||||
op.drop_table('projects')
|
||||
op.drop_table('role_level_mappings')
|
||||
op.drop_table('gmal_service_lines')
|
||||
op.drop_table('gmal_hours')
|
||||
op.drop_table('roles')
|
||||
op.drop_table('gmal_assets')
|
||||
|
||||
sa.Enum(name='matchconfidence').drop(op.get_bind(), checkfirst=True)
|
||||
sa.Enum(name='projectstatus').drop(op.get_bind(), checkfirst=True)
|
||||
sa.Enum(name='modeltype').drop(op.get_bind(), checkfirst=True)
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
8
backend/app/api/deps.py
Normal file
8
backend/app/api/deps.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
|
||||
|
||||
async def get_session(db: AsyncSession = Depends(get_db)):
|
||||
return db
|
||||
160
backend/app/api/gmal.py
Normal file
160
backend/app/api/gmal.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""GMAL asset browse/search endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy import select, func, distinct
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.gmal import GmalAsset, Role, GmalHours
|
||||
from app.models.gmal import ModelType
|
||||
from app.schemas.gmal import GmalAssetBrief, GmalAssetDetail, GmalAssetWithHours, GmalHoursOut, RoleOut, GmalStatsOut, GmalAssetUpdate, GmalHoursUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/assets", response_model=list[GmalAssetBrief])
|
||||
async def list_assets(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
search: str | None = None,
|
||||
sub_category: str | None = None,
|
||||
category: str | None = None,
|
||||
complexity_level: int | None = None,
|
||||
has_hours: bool | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
):
|
||||
query = select(GmalAsset)
|
||||
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
query = query.where(
|
||||
GmalAsset.gmal_id.ilike(pattern)
|
||||
| GmalAsset.asset_name.ilike(pattern)
|
||||
| GmalAsset.unique_name.ilike(pattern)
|
||||
| GmalAsset.asset_description.ilike(pattern)
|
||||
)
|
||||
if sub_category:
|
||||
query = query.where(GmalAsset.sub_category == sub_category)
|
||||
if category:
|
||||
query = query.where(GmalAsset.category == category)
|
||||
if complexity_level:
|
||||
query = query.where(GmalAsset.complexity_level == complexity_level)
|
||||
if has_hours is not None:
|
||||
query = query.where(GmalAsset.has_hour_routes == has_hours)
|
||||
|
||||
query = query.order_by(GmalAsset.gmal_id).offset(skip).limit(limit)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/assets/{gmal_id}", response_model=GmalAssetWithHours)
|
||||
async def get_asset(gmal_id: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(GmalAsset).where(GmalAsset.gmal_id == gmal_id)
|
||||
)
|
||||
asset = result.scalar_one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail=f"GMAL asset {gmal_id} not found")
|
||||
|
||||
# Load hours with role info
|
||||
hours_result = await db.execute(
|
||||
select(GmalHours, Role)
|
||||
.join(Role, GmalHours.role_id == Role.id)
|
||||
.where(GmalHours.gmal_asset_id == asset.id)
|
||||
.order_by(Role.sort_order)
|
||||
)
|
||||
|
||||
hours_by_role = []
|
||||
for gh, role in hours_result.all():
|
||||
hours_by_role.append(GmalHoursOut(
|
||||
role_id=role.id,
|
||||
role_title=role.role_title,
|
||||
discipline=role.discipline,
|
||||
model_type=gh.model_type.value,
|
||||
hours=float(gh.hours),
|
||||
))
|
||||
|
||||
return GmalAssetWithHours(
|
||||
**{k: v for k, v in asset.__dict__.items() if not k.startswith("_")},
|
||||
hours_by_role=hours_by_role,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/assets/{gmal_id}", response_model=GmalAssetDetail)
|
||||
async def update_asset(gmal_id: str, data: GmalAssetUpdate, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(GmalAsset).where(GmalAsset.gmal_id == gmal_id))
|
||||
asset = result.scalar_one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail=f"GMAL asset {gmal_id} not found")
|
||||
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(asset, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(asset)
|
||||
return asset
|
||||
|
||||
|
||||
@router.put("/assets/{gmal_id}/hours")
|
||||
async def update_asset_hours(gmal_id: str, hours: list[GmalHoursUpdate], db: AsyncSession = Depends(get_db)):
|
||||
"""Bulk update hours for a GMAL asset. Replaces existing hours for the given role+model combos."""
|
||||
result = await db.execute(select(GmalAsset).where(GmalAsset.gmal_id == gmal_id))
|
||||
asset = result.scalar_one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail=f"GMAL asset {gmal_id} not found")
|
||||
|
||||
updated = 0
|
||||
for h in hours:
|
||||
mt = ModelType(h.model_type)
|
||||
existing = await db.execute(
|
||||
select(GmalHours).where(
|
||||
GmalHours.gmal_asset_id == asset.id,
|
||||
GmalHours.role_id == h.role_id,
|
||||
GmalHours.model_type == mt,
|
||||
)
|
||||
)
|
||||
gh = existing.scalar_one_or_none()
|
||||
|
||||
if h.hours == 0 and gh:
|
||||
await db.delete(gh)
|
||||
updated += 1
|
||||
elif h.hours != 0 and gh:
|
||||
gh.hours = h.hours
|
||||
updated += 1
|
||||
elif h.hours != 0 and not gh:
|
||||
db.add(GmalHours(
|
||||
gmal_asset_id=asset.id,
|
||||
role_id=h.role_id,
|
||||
model_type=mt,
|
||||
hours=h.hours,
|
||||
))
|
||||
updated += 1
|
||||
|
||||
await db.commit()
|
||||
return {"detail": f"Updated {updated} hour records"}
|
||||
|
||||
|
||||
@router.get("/roles", response_model=list[RoleOut])
|
||||
async def list_roles(db: AsyncSession = Depends(get_db), discipline: str | None = None):
|
||||
query = select(Role).order_by(Role.sort_order)
|
||||
if discipline:
|
||||
query = query.where(Role.discipline == discipline)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/stats", response_model=GmalStatsOut)
|
||||
async def get_stats(db: AsyncSession = Depends(get_db)):
|
||||
assets_count = await db.execute(select(func.count(GmalAsset.id)))
|
||||
roles_count = await db.execute(select(func.count(Role.id)))
|
||||
hours_count = await db.execute(select(func.count(GmalHours.id)))
|
||||
categories = await db.execute(select(distinct(GmalAsset.category)).where(GmalAsset.category.isnot(None)))
|
||||
sub_cats = await db.execute(select(distinct(GmalAsset.sub_category)).where(GmalAsset.sub_category.isnot(None)))
|
||||
|
||||
return GmalStatsOut(
|
||||
total_assets=assets_count.scalar() or 0,
|
||||
total_roles=roles_count.scalar() or 0,
|
||||
total_hours_records=hours_count.scalar() or 0,
|
||||
categories=sorted([r[0] for r in categories.all()]),
|
||||
sub_categories=sorted([r[0] for r in sub_cats.all()]),
|
||||
)
|
||||
50
backend/app/api/ingest.py
Normal file
50
backend/app/api/ingest.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""GMAL data ingestion endpoint."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.schemas.gmal import IngestResult
|
||||
from app.services.excel_parser import parse_gmal_workbook
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/ingest", response_model=IngestResult)
|
||||
async def ingest_gmal_data(file: UploadFile | None = File(None)):
|
||||
"""Ingest GMAL data from Excel file.
|
||||
|
||||
If no file is uploaded, uses the default file from the data directory.
|
||||
Uses a synchronous DB session since openpyxl is synchronous.
|
||||
"""
|
||||
if file:
|
||||
# Save uploaded file temporarily
|
||||
filepath = f"/tmp/{file.filename}"
|
||||
content = await file.read()
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(content)
|
||||
else:
|
||||
# Use default data file
|
||||
filepath = os.path.join(settings.data_dir, "U-Studio GMAL Asset Job Routes Apr25 ForFranky.xlsx")
|
||||
if not os.path.exists(filepath):
|
||||
raise HTTPException(status_code=404, detail="Default GMAL file not found in data directory")
|
||||
|
||||
# Use sync engine for openpyxl parsing
|
||||
sync_engine = create_engine(settings.database_url_sync)
|
||||
|
||||
try:
|
||||
with Session(sync_engine) as db:
|
||||
result = parse_gmal_workbook(filepath, db)
|
||||
return IngestResult(**result)
|
||||
except Exception as e:
|
||||
logger.error(f"Ingestion failed: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Ingestion failed: {str(e)}")
|
||||
finally:
|
||||
sync_engine.dispose()
|
||||
if file and os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
279
backend/app/api/matching.py
Normal file
279
backend/app/api/matching.py
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
"""Client document upload and AI matching endpoints."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.gmal import GmalAsset
|
||||
from app.models.project import Project, ClientAsset, Match, ProjectStatus, MatchConfidence
|
||||
from app.schemas.project import ClientAssetOut, ClientAssetUpdate, MatchOut, MatchSelectRequest, ManualMatchRequest
|
||||
from app.services.doc_parser import parse_uploaded_file
|
||||
from app.services.ai_matching import match_client_assets
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/{project_id}/upload")
|
||||
async def upload_client_document(
|
||||
project_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Upload a client document and extract assets using AI."""
|
||||
project = await _get_project(project_id, db)
|
||||
|
||||
# Read file
|
||||
content = await file.read()
|
||||
project.source_filename = file.filename
|
||||
project.status = ProjectStatus.PARSING
|
||||
|
||||
# Parse document to extract assets
|
||||
try:
|
||||
extracted, usage_info = parse_uploaded_file(content, file.filename)
|
||||
except Exception as e:
|
||||
logger.error(f"Document parsing failed: {e}")
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse document: {str(e)}")
|
||||
|
||||
# Save AI costs to project
|
||||
project.ai_input_tokens = (project.ai_input_tokens or 0) + usage_info.get("input_tokens", 0)
|
||||
project.ai_output_tokens = (project.ai_output_tokens or 0) + usage_info.get("output_tokens", 0)
|
||||
project.ai_cost_usd = float(project.ai_cost_usd or 0) + usage_info.get("cost_usd", 0)
|
||||
project.ai_call_count = (project.ai_call_count or 0) + 1
|
||||
|
||||
# Clear existing client assets for this project
|
||||
existing = await db.execute(
|
||||
select(ClientAsset).where(ClientAsset.project_id == project_id)
|
||||
)
|
||||
for ca in existing.scalars().all():
|
||||
await db.delete(ca)
|
||||
|
||||
# Create client asset records
|
||||
assets = []
|
||||
for idx, item in enumerate(extracted):
|
||||
ca = ClientAsset(
|
||||
project_id=project_id,
|
||||
raw_name=item.get("name", "Unknown"),
|
||||
raw_description=item.get("description", ""),
|
||||
volume=item.get("volume", 1),
|
||||
sort_order=idx + 1,
|
||||
)
|
||||
db.add(ca)
|
||||
assets.append(ca)
|
||||
|
||||
project.status = ProjectStatus.REVIEW
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Extracted {len(assets)} assets from {file.filename}",
|
||||
"asset_count": len(assets),
|
||||
"assets": [
|
||||
{"name": a.raw_name, "description": a.raw_description, "volume": a.volume}
|
||||
for a in assets
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{project_id}/client-assets", response_model=list[ClientAssetOut])
|
||||
async def list_client_assets(project_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(ClientAsset)
|
||||
.where(ClientAsset.project_id == project_id)
|
||||
.order_by(ClientAsset.sort_order)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.put("/{project_id}/client-assets/{asset_id}", response_model=ClientAssetOut)
|
||||
async def update_client_asset(
|
||||
project_id: int,
|
||||
asset_id: int,
|
||||
data: ClientAssetUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(ClientAsset).where(ClientAsset.id == asset_id, ClientAsset.project_id == project_id)
|
||||
)
|
||||
ca = result.scalar_one_or_none()
|
||||
if not ca:
|
||||
raise HTTPException(status_code=404, detail="Client asset not found")
|
||||
|
||||
if data.raw_name is not None:
|
||||
ca.raw_name = data.raw_name
|
||||
if data.raw_description is not None:
|
||||
ca.raw_description = data.raw_description
|
||||
if data.volume is not None:
|
||||
ca.volume = data.volume
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ca)
|
||||
return ca
|
||||
|
||||
|
||||
@router.post("/{project_id}/match")
|
||||
async def run_matching(project_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Trigger AI matching for all client assets in this project."""
|
||||
project = await _get_project(project_id, db)
|
||||
|
||||
# Get client assets
|
||||
result = await db.execute(
|
||||
select(ClientAsset).where(ClientAsset.project_id == project_id).order_by(ClientAsset.sort_order)
|
||||
)
|
||||
client_assets = result.scalars().all()
|
||||
|
||||
if not client_assets:
|
||||
raise HTTPException(status_code=400, detail="No client assets to match. Upload a document first.")
|
||||
|
||||
# Clear existing matches
|
||||
for ca in client_assets:
|
||||
matches_result = await db.execute(select(Match).where(Match.client_asset_id == ca.id))
|
||||
for m in matches_result.scalars().all():
|
||||
await db.delete(m)
|
||||
|
||||
project.status = ProjectStatus.MATCHING
|
||||
await db.commit()
|
||||
|
||||
# Run matching (batched, parallel, commits per batch)
|
||||
matches = await match_client_assets(db, project_id, client_assets)
|
||||
|
||||
# Refresh project and set final status
|
||||
await db.refresh(project)
|
||||
project.status = ProjectStatus.REVIEW
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Matched {len(client_assets)} client assets",
|
||||
"total_matches": len(matches),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{project_id}/match/cancel")
|
||||
async def cancel_matching_endpoint(project_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Cancel an in-progress matching run."""
|
||||
from app.services.ai_matching import cancel_matching
|
||||
cancel_matching(project_id)
|
||||
project = await _get_project(project_id, db)
|
||||
project.status = ProjectStatus.REVIEW
|
||||
await db.commit()
|
||||
return {"detail": "Matching cancellation requested"}
|
||||
|
||||
|
||||
@router.get("/{project_id}/matches", response_model=list[MatchOut])
|
||||
async def list_matches(project_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get all matches for a project, grouped by client asset."""
|
||||
# Get client asset IDs for this project
|
||||
ca_result = await db.execute(
|
||||
select(ClientAsset.id).where(ClientAsset.project_id == project_id)
|
||||
)
|
||||
ca_ids = [r[0] for r in ca_result.all()]
|
||||
|
||||
if not ca_ids:
|
||||
return []
|
||||
|
||||
result = await db.execute(
|
||||
select(Match, GmalAsset)
|
||||
.join(GmalAsset, Match.gmal_asset_id == GmalAsset.id)
|
||||
.where(Match.client_asset_id.in_(ca_ids))
|
||||
.order_by(Match.client_asset_id, Match.rank)
|
||||
)
|
||||
|
||||
matches = []
|
||||
for match, gmal in result.all():
|
||||
matches.append(MatchOut(
|
||||
id=match.id,
|
||||
client_asset_id=match.client_asset_id,
|
||||
gmal_asset_id=match.gmal_asset_id,
|
||||
gmal_id=gmal.gmal_id,
|
||||
gmal_name=gmal.asset_name,
|
||||
gmal_unique_name=gmal.unique_name,
|
||||
confidence=match.confidence.value,
|
||||
confidence_score=float(match.confidence_score) if match.confidence_score else None,
|
||||
ai_reasoning=match.ai_reasoning,
|
||||
caveat_text=match.caveat_text,
|
||||
is_selected=match.is_selected,
|
||||
rank=match.rank,
|
||||
))
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
@router.put("/{project_id}/matches/{match_id}/select")
|
||||
async def select_match(
|
||||
project_id: int,
|
||||
match_id: int,
|
||||
data: MatchSelectRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Select or deselect a match. Deselects other matches for the same client asset."""
|
||||
result = await db.execute(select(Match).where(Match.id == match_id))
|
||||
match = result.scalar_one_or_none()
|
||||
if not match:
|
||||
raise HTTPException(status_code=404, detail="Match not found")
|
||||
|
||||
if data.is_selected:
|
||||
# Deselect all other matches for this client asset
|
||||
siblings = await db.execute(
|
||||
select(Match).where(Match.client_asset_id == match.client_asset_id)
|
||||
)
|
||||
for sibling in siblings.scalars().all():
|
||||
sibling.is_selected = False
|
||||
|
||||
match.is_selected = data.is_selected
|
||||
await db.commit()
|
||||
|
||||
return {"detail": "Match updated"}
|
||||
|
||||
|
||||
@router.post("/{project_id}/matches/{client_asset_id}/manual")
|
||||
async def manual_match(
|
||||
project_id: int,
|
||||
client_asset_id: int,
|
||||
data: ManualMatchRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Manually assign a GMAL asset to a client asset."""
|
||||
# Verify client asset belongs to project
|
||||
ca_result = await db.execute(
|
||||
select(ClientAsset).where(ClientAsset.id == client_asset_id, ClientAsset.project_id == project_id)
|
||||
)
|
||||
ca = ca_result.scalar_one_or_none()
|
||||
if not ca:
|
||||
raise HTTPException(status_code=404, detail="Client asset not found")
|
||||
|
||||
# Verify GMAL asset exists
|
||||
gmal_result = await db.execute(select(GmalAsset).where(GmalAsset.id == data.gmal_asset_id))
|
||||
gmal = gmal_result.scalar_one_or_none()
|
||||
if not gmal:
|
||||
raise HTTPException(status_code=404, detail="GMAL asset not found")
|
||||
|
||||
# Deselect existing matches
|
||||
existing = await db.execute(select(Match).where(Match.client_asset_id == client_asset_id))
|
||||
for m in existing.scalars().all():
|
||||
m.is_selected = False
|
||||
|
||||
# Create manual match
|
||||
match = Match(
|
||||
client_asset_id=client_asset_id,
|
||||
gmal_asset_id=data.gmal_asset_id,
|
||||
confidence=MatchConfidence.EXACT,
|
||||
confidence_score=1.0,
|
||||
ai_reasoning="Manually assigned by user",
|
||||
caveat_text="",
|
||||
is_selected=True,
|
||||
rank=0,
|
||||
)
|
||||
db.add(match)
|
||||
await db.commit()
|
||||
|
||||
return {"detail": f"Manually matched to {gmal.gmal_id}"}
|
||||
|
||||
|
||||
async def _get_project(project_id: int, db: AsyncSession) -> Project:
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return project
|
||||
107
backend/app/api/projects.py
Normal file
107
backend/app/api/projects.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""Project CRUD endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.project import Project, ClientAsset, ProjectStatus
|
||||
from app.models.gmal import ModelType
|
||||
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=ProjectOut)
|
||||
async def create_project(data: ProjectCreate, db: AsyncSession = Depends(get_db)):
|
||||
project = Project(
|
||||
name=data.name,
|
||||
client_name=data.client_name,
|
||||
description=data.description,
|
||||
model_type=ModelType(data.model_type),
|
||||
)
|
||||
db.add(project)
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
return _project_out(project, 0)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProjectOut])
|
||||
async def list_projects(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Project).order_by(Project.created_at.desc()))
|
||||
projects = result.scalars().all()
|
||||
|
||||
out = []
|
||||
for p in projects:
|
||||
count_result = await db.execute(
|
||||
select(func.count(ClientAsset.id)).where(ClientAsset.project_id == p.id)
|
||||
)
|
||||
count = count_result.scalar() or 0
|
||||
out.append(_project_out(p, count))
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectOut)
|
||||
async def get_project(project_id: int, db: AsyncSession = Depends(get_db)):
|
||||
project = await _get_project(project_id, db)
|
||||
count_result = await db.execute(
|
||||
select(func.count(ClientAsset.id)).where(ClientAsset.project_id == project.id)
|
||||
)
|
||||
return _project_out(project, count_result.scalar() or 0)
|
||||
|
||||
|
||||
@router.put("/{project_id}", response_model=ProjectOut)
|
||||
async def update_project(project_id: int, data: ProjectUpdate, db: AsyncSession = Depends(get_db)):
|
||||
project = await _get_project(project_id, db)
|
||||
|
||||
if data.name is not None:
|
||||
project.name = data.name
|
||||
if data.client_name is not None:
|
||||
project.client_name = data.client_name
|
||||
if data.description is not None:
|
||||
project.description = data.description
|
||||
if data.model_type is not None:
|
||||
project.model_type = ModelType(data.model_type)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
|
||||
count_result = await db.execute(
|
||||
select(func.count(ClientAsset.id)).where(ClientAsset.project_id == project.id)
|
||||
)
|
||||
return _project_out(project, count_result.scalar() or 0)
|
||||
|
||||
|
||||
@router.delete("/{project_id}")
|
||||
async def delete_project(project_id: int, db: AsyncSession = Depends(get_db)):
|
||||
project = await _get_project(project_id, db)
|
||||
await db.delete(project)
|
||||
await db.commit()
|
||||
return {"detail": "Project deleted"}
|
||||
|
||||
|
||||
async def _get_project(project_id: int, db: AsyncSession) -> Project:
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return project
|
||||
|
||||
|
||||
def _project_out(project: Project, asset_count: int) -> ProjectOut:
|
||||
return ProjectOut(
|
||||
id=project.id,
|
||||
name=project.name,
|
||||
client_name=project.client_name,
|
||||
description=project.description,
|
||||
model_type=project.model_type.value,
|
||||
status=project.status.value,
|
||||
source_filename=project.source_filename,
|
||||
ai_input_tokens=project.ai_input_tokens or 0,
|
||||
ai_output_tokens=project.ai_output_tokens or 0,
|
||||
ai_cost_usd=float(project.ai_cost_usd or 0),
|
||||
ai_call_count=project.ai_call_count or 0,
|
||||
created_at=project.created_at,
|
||||
updated_at=project.updated_at,
|
||||
asset_count=asset_count,
|
||||
)
|
||||
163
backend/app/api/ratecard.py
Normal file
163
backend/app/api/ratecard.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""Ratecard build and export endpoints."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.gmal import GmalAsset, Role
|
||||
from app.models.project import Project, ClientAsset, RatecardLine, ProjectStatus
|
||||
from app.schemas.project import RatecardLineOut, RatecardLineUpdate, RatecardSummary
|
||||
from app.services.ratecard_builder import build_ratecard
|
||||
from app.services.export_excel import export_ratecard_excel
|
||||
from app.services.export_pdf import export_caveats_pdf
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/{project_id}/ratecard/build")
|
||||
async def build_project_ratecard(project_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Build ratecard from confirmed matches."""
|
||||
project = await _get_project(project_id, db)
|
||||
project.status = ProjectStatus.BUILDING
|
||||
|
||||
lines = await build_ratecard(db, project)
|
||||
|
||||
project.status = ProjectStatus.FINALIZED
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Ratecard built with {len(lines)} lines",
|
||||
"total_lines": len(lines),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{project_id}/ratecard", response_model=RatecardSummary)
|
||||
async def get_ratecard(project_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get the full ratecard for a project."""
|
||||
project = await _get_project(project_id, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(RatecardLine, ClientAsset, GmalAsset, Role)
|
||||
.join(ClientAsset, RatecardLine.client_asset_id == ClientAsset.id)
|
||||
.join(GmalAsset, RatecardLine.gmal_asset_id == GmalAsset.id)
|
||||
.join(Role, RatecardLine.role_id == Role.id)
|
||||
.where(RatecardLine.project_id == project_id)
|
||||
.order_by(Role.sort_order, ClientAsset.sort_order)
|
||||
)
|
||||
|
||||
lines = []
|
||||
total_hours = 0
|
||||
for rl, ca, gmal, role in result.all():
|
||||
effective = float(rl.manual_override) if rl.manual_override is not None else float(rl.total_hours or 0)
|
||||
total_hours += effective
|
||||
lines.append(RatecardLineOut(
|
||||
id=rl.id,
|
||||
client_asset_id=rl.client_asset_id,
|
||||
client_asset_name=ca.raw_name,
|
||||
gmal_asset_id=rl.gmal_asset_id,
|
||||
gmal_id=gmal.gmal_id,
|
||||
role_id=rl.role_id,
|
||||
role_title=role.role_title,
|
||||
discipline=role.discipline,
|
||||
base_hours=float(rl.base_hours) if rl.base_hours else None,
|
||||
volume=rl.volume,
|
||||
total_hours=float(rl.total_hours) if rl.total_hours else None,
|
||||
manual_override=float(rl.manual_override) if rl.manual_override is not None else None,
|
||||
notes=rl.notes,
|
||||
))
|
||||
|
||||
# Count unique client assets
|
||||
asset_ids = set(l.client_asset_id for l in lines)
|
||||
|
||||
return RatecardSummary(
|
||||
project_id=project.id,
|
||||
project_name=project.name,
|
||||
model_type=project.model_type.value,
|
||||
total_assets=len(asset_ids),
|
||||
total_hours=round(total_hours, 2),
|
||||
lines=lines,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{project_id}/ratecard/lines/{line_id}", response_model=RatecardLineOut)
|
||||
async def update_ratecard_line(
|
||||
project_id: int,
|
||||
line_id: int,
|
||||
data: RatecardLineUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(RatecardLine).where(RatecardLine.id == line_id, RatecardLine.project_id == project_id)
|
||||
)
|
||||
line = result.scalar_one_or_none()
|
||||
if not line:
|
||||
raise HTTPException(status_code=404, detail="Ratecard line not found")
|
||||
|
||||
if data.manual_override is not None:
|
||||
line.manual_override = data.manual_override
|
||||
if data.notes is not None:
|
||||
line.notes = data.notes
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(line)
|
||||
|
||||
# Re-fetch with joins for response
|
||||
full_result = await db.execute(
|
||||
select(RatecardLine, ClientAsset, GmalAsset, Role)
|
||||
.join(ClientAsset, RatecardLine.client_asset_id == ClientAsset.id)
|
||||
.join(GmalAsset, RatecardLine.gmal_asset_id == GmalAsset.id)
|
||||
.join(Role, RatecardLine.role_id == Role.id)
|
||||
.where(RatecardLine.id == line_id)
|
||||
)
|
||||
rl, ca, gmal, role = full_result.one()
|
||||
|
||||
return RatecardLineOut(
|
||||
id=rl.id,
|
||||
client_asset_id=rl.client_asset_id,
|
||||
client_asset_name=ca.raw_name,
|
||||
gmal_asset_id=rl.gmal_asset_id,
|
||||
gmal_id=gmal.gmal_id,
|
||||
role_id=rl.role_id,
|
||||
role_title=role.role_title,
|
||||
discipline=role.discipline,
|
||||
base_hours=float(rl.base_hours) if rl.base_hours else None,
|
||||
volume=rl.volume,
|
||||
total_hours=float(rl.total_hours) if rl.total_hours else None,
|
||||
manual_override=float(rl.manual_override) if rl.manual_override is not None else None,
|
||||
notes=rl.notes,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/ratecard/export/excel")
|
||||
async def export_excel(project_id: int, db: AsyncSession = Depends(get_db)):
|
||||
project = await _get_project(project_id, db)
|
||||
data = await export_ratecard_excel(db, project)
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f"attachment; filename={project.name}_ratecard.xlsx"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/ratecard/export/pdf")
|
||||
async def export_pdf(project_id: int, db: AsyncSession = Depends(get_db)):
|
||||
project = await _get_project(project_id, db)
|
||||
data = await export_caveats_pdf(db, project)
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f"attachment; filename={project.name}_caveats.pdf"},
|
||||
)
|
||||
|
||||
|
||||
async def _get_project(project_id: int, db: AsyncSession) -> Project:
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return project
|
||||
14
backend/app/config.py
Normal file
14
backend/app/config.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
database_url: str = "postgresql+asyncpg://scope_user:scope_pass_2024@db:5432/scope_builder"
|
||||
database_url_sync: str = "postgresql://scope_user:scope_pass_2024@db:5432/scope_builder"
|
||||
anthropic_api_key: str = ""
|
||||
data_dir: str = "/app/data"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
16
backend/app/database.py
Normal file
16
backend/app/database.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(settings.database_url, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
44
backend/app/main.py
Normal file
44
backend/app/main.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api import gmal, ingest, projects, matching, ratecard
|
||||
|
||||
app = FastAPI(title="Scope Builder", version="1.0.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://localhost:3001", "http://localhost:3010"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(gmal.router, prefix="/api/gmal", tags=["GMAL"])
|
||||
app.include_router(ingest.router, prefix="/api/gmal", tags=["Ingest"])
|
||||
app.include_router(projects.router, prefix="/api/projects", tags=["Projects"])
|
||||
app.include_router(matching.router, prefix="/api/projects", tags=["Matching"])
|
||||
app.include_router(ratecard.router, prefix="/api/projects", tags=["Ratecard"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/ai/usage")
|
||||
async def ai_usage():
|
||||
from app.utils.claude_client import get_usage_stats
|
||||
return get_usage_stats()
|
||||
|
||||
|
||||
@app.post("/api/ai/usage/reset")
|
||||
async def ai_usage_reset():
|
||||
from app.utils.claude_client import reset_usage_stats
|
||||
reset_usage_stats()
|
||||
return {"detail": "Usage stats reset"}
|
||||
|
||||
|
||||
@app.get("/api/ai/debug")
|
||||
async def ai_debug():
|
||||
from app.utils.claude_client import get_debug_log
|
||||
return get_debug_log()
|
||||
7
backend/app/models/__init__.py
Normal file
7
backend/app/models/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from app.models.gmal import GmalAsset, Role, GmalHours, GmalServiceLine, RoleLevelMapping
|
||||
from app.models.project import Project, ClientAsset, Match, RatecardLine
|
||||
|
||||
__all__ = [
|
||||
"GmalAsset", "Role", "GmalHours", "GmalServiceLine", "RoleLevelMapping",
|
||||
"Project", "ClientAsset", "Match", "RatecardLine",
|
||||
]
|
||||
110
backend/app/models/gmal.py
Normal file
110
backend/app/models/gmal.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Text, Integer, Numeric, Boolean, DateTime, Enum, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ModelType(str, enum.Enum):
|
||||
CURRENT_OPLUS = "current_oplus"
|
||||
AI_OPLUS = "ai_oplus"
|
||||
CURRENT_LOCAL = "current_local"
|
||||
AI_LOCAL = "ai_local"
|
||||
ASSET_FACTORY = "asset_factory"
|
||||
|
||||
|
||||
# Map spreadsheet header text -> enum
|
||||
MODEL_TYPE_MAP = {
|
||||
"Current Model - O+ Market": ModelType.CURRENT_OPLUS,
|
||||
"AI Model - O+ Market": ModelType.AI_OPLUS,
|
||||
"Current Model - Local Market": ModelType.CURRENT_LOCAL,
|
||||
"AI Model - Local Market": ModelType.AI_LOCAL,
|
||||
"Asset Factory Model": ModelType.ASSET_FACTORY,
|
||||
}
|
||||
|
||||
|
||||
class GmalAsset(Base):
|
||||
__tablename__ = "gmal_assets"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
gmal_id: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True)
|
||||
swop_asset_id: Mapped[str | None] = mapped_column(String(50))
|
||||
region: Mapped[str | None] = mapped_column(String(20))
|
||||
category: Mapped[str | None] = mapped_column(String(100))
|
||||
sub_category: Mapped[str | None] = mapped_column(String(100), index=True)
|
||||
sub_category_description: Mapped[str | None] = mapped_column(Text)
|
||||
asset_name: Mapped[str | None] = mapped_column(String(255))
|
||||
complexity_level: Mapped[int | None] = mapped_column(Integer)
|
||||
complexity_name: Mapped[str | None] = mapped_column(String(20))
|
||||
unique_name: Mapped[str | None] = mapped_column(String(255))
|
||||
asset_description: Mapped[str | None] = mapped_column(Text)
|
||||
complexity_description: Mapped[str | None] = mapped_column(Text)
|
||||
caveats: Mapped[str | None] = mapped_column(Text)
|
||||
ai_enhanced_description: Mapped[str | None] = mapped_column(Text)
|
||||
master_adapt: Mapped[str | None] = mapped_column(String(20))
|
||||
ai_efficiency_pct: Mapped[float | None] = mapped_column(Numeric(5, 2))
|
||||
has_hour_routes: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
hours: Mapped[list["GmalHours"]] = relationship(back_populates="asset", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
discipline: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
role_title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
entity: Mapped[str | None] = mapped_column(String(100))
|
||||
resource_location: Mapped[str | None] = mapped_column(String(50))
|
||||
unique_name: Mapped[str | None] = mapped_column(String(255))
|
||||
sort_order: Mapped[int | None] = mapped_column(Integer)
|
||||
is_programme_role: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("role_title", "entity", name="uq_role_title_entity"),
|
||||
)
|
||||
|
||||
hours: Mapped[list["GmalHours"]] = relationship(back_populates="role")
|
||||
|
||||
|
||||
class GmalHours(Base):
|
||||
__tablename__ = "gmal_hours"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
gmal_asset_id: Mapped[int] = mapped_column(ForeignKey("gmal_assets.id"), nullable=False)
|
||||
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"), nullable=False)
|
||||
model_type: Mapped[ModelType] = mapped_column(Enum(ModelType), nullable=False)
|
||||
hours: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
|
||||
asset: Mapped["GmalAsset"] = relationship(back_populates="hours")
|
||||
role: Mapped["Role"] = relationship(back_populates="hours")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("gmal_asset_id", "role_id", "model_type", name="uq_gmal_role_model"),
|
||||
Index("idx_gmal_hours_asset", "gmal_asset_id"),
|
||||
Index("idx_gmal_hours_model", "model_type"),
|
||||
)
|
||||
|
||||
|
||||
class GmalServiceLine(Base):
|
||||
__tablename__ = "gmal_service_lines"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
number: Mapped[str | None] = mapped_column(String(20))
|
||||
name: Mapped[str | None] = mapped_column(String(255))
|
||||
type: Mapped[str | None] = mapped_column(String(50))
|
||||
gmal_id: Mapped[str | None] = mapped_column(String(50), index=True)
|
||||
|
||||
|
||||
class RoleLevelMapping(Base):
|
||||
__tablename__ = "role_level_mappings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
role_name: Mapped[str | None] = mapped_column(String(200))
|
||||
number: Mapped[str | None] = mapped_column(String(20))
|
||||
level_name: Mapped[str | None] = mapped_column(String(200))
|
||||
type: Mapped[str | None] = mapped_column(String(50))
|
||||
106
backend/app/models/project.py
Normal file
106
backend/app/models/project.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Text, Integer, Numeric, Boolean, DateTime, Enum, ForeignKey, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
from app.models.gmal import ModelType
|
||||
|
||||
|
||||
class ProjectStatus(str, enum.Enum):
|
||||
DRAFT = "draft"
|
||||
PARSING = "parsing"
|
||||
MATCHING = "matching"
|
||||
REVIEW = "review"
|
||||
BUILDING = "building"
|
||||
FINALIZED = "finalized"
|
||||
|
||||
|
||||
class MatchConfidence(str, enum.Enum):
|
||||
EXACT = "exact"
|
||||
CLOSE = "close"
|
||||
MULTIPLE = "multiple"
|
||||
NONE = "none"
|
||||
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
client_name: Mapped[str | None] = mapped_column(String(255))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
model_type: Mapped[ModelType] = mapped_column(Enum(ModelType), default=ModelType.CURRENT_OPLUS)
|
||||
status: Mapped[ProjectStatus] = mapped_column(Enum(ProjectStatus), default=ProjectStatus.DRAFT)
|
||||
source_filename: Mapped[str | None] = mapped_column(String(255))
|
||||
ai_input_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||
ai_output_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||
ai_cost_usd: Mapped[float] = mapped_column(Numeric(10, 6), default=0)
|
||||
ai_call_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
client_assets: Mapped[list["ClientAsset"]] = relationship(back_populates="project", cascade="all, delete-orphan")
|
||||
ratecard_lines: Mapped[list["RatecardLine"]] = relationship(back_populates="project", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ClientAsset(Base):
|
||||
__tablename__ = "client_assets"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
raw_name: Mapped[str | None] = mapped_column(String(500))
|
||||
raw_description: Mapped[str | None] = mapped_column(Text)
|
||||
volume: Mapped[int] = mapped_column(Integer, default=1)
|
||||
sort_order: Mapped[int | None] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
project: Mapped["Project"] = relationship(back_populates="client_assets")
|
||||
matches: Mapped[list["Match"]] = relationship(back_populates="client_asset", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Match(Base):
|
||||
__tablename__ = "matches"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
client_asset_id: Mapped[int] = mapped_column(ForeignKey("client_assets.id", ondelete="CASCADE"), nullable=False)
|
||||
gmal_asset_id: Mapped[int] = mapped_column(ForeignKey("gmal_assets.id"), nullable=False)
|
||||
confidence: Mapped[MatchConfidence] = mapped_column(Enum(MatchConfidence), nullable=False)
|
||||
confidence_score: Mapped[float | None] = mapped_column(Numeric(3, 2))
|
||||
ai_reasoning: Mapped[str | None] = mapped_column(Text)
|
||||
caveat_text: Mapped[str | None] = mapped_column(Text)
|
||||
is_selected: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
rank: Mapped[int] = mapped_column(Integer, default=1)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
client_asset: Mapped["ClientAsset"] = relationship(back_populates="matches")
|
||||
gmal_asset: Mapped["GmalAsset"] = relationship()
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_matches_client_asset", "client_asset_id"),
|
||||
)
|
||||
|
||||
|
||||
class RatecardLine(Base):
|
||||
__tablename__ = "ratecard_lines"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
client_asset_id: Mapped[int] = mapped_column(ForeignKey("client_assets.id"), nullable=False)
|
||||
gmal_asset_id: Mapped[int] = mapped_column(ForeignKey("gmal_assets.id"), nullable=False)
|
||||
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"), nullable=False)
|
||||
base_hours: Mapped[float | None] = mapped_column(Numeric(10, 2))
|
||||
volume: Mapped[int] = mapped_column(Integer, default=1)
|
||||
total_hours: Mapped[float | None] = mapped_column(Numeric(10, 2))
|
||||
manual_override: Mapped[float | None] = mapped_column(Numeric(10, 2))
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
project: Mapped["Project"] = relationship(back_populates="ratecard_lines")
|
||||
client_asset: Mapped["ClientAsset"] = relationship()
|
||||
gmal_asset: Mapped["GmalAsset"] = relationship()
|
||||
role: Mapped["Role"] = relationship()
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_ratecard_project", "project_id"),
|
||||
)
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
96
backend/app/schemas/gmal.py
Normal file
96
backend/app/schemas/gmal.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class GmalAssetBrief(BaseModel):
|
||||
id: int
|
||||
gmal_id: str
|
||||
asset_name: str | None
|
||||
sub_category: str | None
|
||||
complexity_level: int | None
|
||||
complexity_name: str | None
|
||||
unique_name: str | None
|
||||
has_hour_routes: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GmalAssetDetail(GmalAssetBrief):
|
||||
swop_asset_id: str | None
|
||||
region: str | None
|
||||
category: str | None
|
||||
sub_category_description: str | None
|
||||
asset_description: str | None
|
||||
complexity_description: str | None
|
||||
caveats: str | None
|
||||
ai_enhanced_description: str | None
|
||||
master_adapt: str | None
|
||||
ai_efficiency_pct: float | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class RoleOut(BaseModel):
|
||||
id: int
|
||||
discipline: str
|
||||
role_title: str
|
||||
entity: str | None
|
||||
resource_location: str | None
|
||||
unique_name: str | None
|
||||
sort_order: int | None
|
||||
is_programme_role: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GmalHoursOut(BaseModel):
|
||||
role_id: int
|
||||
role_title: str
|
||||
discipline: str
|
||||
model_type: str
|
||||
hours: float
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GmalAssetWithHours(GmalAssetDetail):
|
||||
hours_by_role: list[GmalHoursOut] = []
|
||||
|
||||
|
||||
class GmalStatsOut(BaseModel):
|
||||
total_assets: int
|
||||
total_roles: int
|
||||
total_hours_records: int
|
||||
categories: list[str]
|
||||
sub_categories: list[str]
|
||||
|
||||
|
||||
class GmalAssetUpdate(BaseModel):
|
||||
asset_name: str | None = None
|
||||
sub_category: str | None = None
|
||||
category: str | None = None
|
||||
complexity_level: int | None = None
|
||||
complexity_name: str | None = None
|
||||
unique_name: str | None = None
|
||||
asset_description: str | None = None
|
||||
complexity_description: str | None = None
|
||||
caveats: str | None = None
|
||||
master_adapt: str | None = None
|
||||
ai_efficiency_pct: float | None = None
|
||||
|
||||
|
||||
class GmalHoursUpdate(BaseModel):
|
||||
role_id: int
|
||||
model_type: str
|
||||
hours: float
|
||||
|
||||
|
||||
class IngestResult(BaseModel):
|
||||
assets_loaded: int
|
||||
roles_loaded: int
|
||||
hours_loaded: int
|
||||
service_lines_loaded: int
|
||||
role_mappings_loaded: int
|
||||
113
backend/app/schemas/project.py
Normal file
113
backend/app/schemas/project.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
client_name: str | None = None
|
||||
description: str | None = None
|
||||
model_type: str = "current_oplus"
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
client_name: str | None = None
|
||||
description: str | None = None
|
||||
model_type: str | None = None
|
||||
|
||||
|
||||
class ProjectOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
client_name: str | None
|
||||
description: str | None
|
||||
model_type: str
|
||||
status: str
|
||||
source_filename: str | None
|
||||
ai_input_tokens: int = 0
|
||||
ai_output_tokens: int = 0
|
||||
ai_cost_usd: float = 0
|
||||
ai_call_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
asset_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClientAssetOut(BaseModel):
|
||||
id: int
|
||||
project_id: int
|
||||
raw_name: str | None
|
||||
raw_description: str | None
|
||||
volume: int
|
||||
sort_order: int | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClientAssetUpdate(BaseModel):
|
||||
raw_name: str | None = None
|
||||
raw_description: str | None = None
|
||||
volume: int | None = None
|
||||
|
||||
|
||||
class MatchOut(BaseModel):
|
||||
id: int
|
||||
client_asset_id: int
|
||||
gmal_asset_id: int
|
||||
gmal_id: str | None = None
|
||||
gmal_name: str | None = None
|
||||
gmal_unique_name: str | None = None
|
||||
confidence: str
|
||||
confidence_score: float | None
|
||||
ai_reasoning: str | None
|
||||
caveat_text: str | None
|
||||
is_selected: bool
|
||||
rank: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MatchSelectRequest(BaseModel):
|
||||
is_selected: bool = True
|
||||
|
||||
|
||||
class ManualMatchRequest(BaseModel):
|
||||
gmal_asset_id: int
|
||||
|
||||
|
||||
class RatecardLineOut(BaseModel):
|
||||
id: int
|
||||
client_asset_id: int
|
||||
client_asset_name: str | None = None
|
||||
gmal_asset_id: int
|
||||
gmal_id: str | None = None
|
||||
role_id: int
|
||||
role_title: str | None = None
|
||||
discipline: str | None = None
|
||||
base_hours: float | None
|
||||
volume: int
|
||||
total_hours: float | None
|
||||
manual_override: float | None
|
||||
notes: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RatecardLineUpdate(BaseModel):
|
||||
manual_override: float | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class RatecardSummary(BaseModel):
|
||||
project_id: int
|
||||
project_name: str
|
||||
model_type: str
|
||||
total_assets: int
|
||||
total_hours: float
|
||||
lines: list[RatecardLineOut]
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
300
backend/app/services/ai_matching.py
Normal file
300
backend/app/services/ai_matching.py
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
"""AI-powered matching of client assets to GMAL catalog using Claude."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.gmal import GmalAsset
|
||||
from app.models.project import ClientAsset, Match, MatchConfidence
|
||||
from app.utils.claude_client import call_claude, extract_tool_result
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cancel flag - set project_id to cancel
|
||||
_cancel_lock = threading.Lock()
|
||||
_cancelled_projects: set[int] = set()
|
||||
|
||||
BATCH_SIZE = 10
|
||||
|
||||
|
||||
def cancel_matching(project_id: int):
|
||||
with _cancel_lock:
|
||||
_cancelled_projects.add(project_id)
|
||||
|
||||
|
||||
def _is_cancelled(project_id: int) -> bool:
|
||||
with _cancel_lock:
|
||||
return project_id in _cancelled_projects
|
||||
|
||||
|
||||
def _clear_cancel(project_id: int):
|
||||
with _cancel_lock:
|
||||
_cancelled_projects.discard(project_id)
|
||||
|
||||
|
||||
MATCH_TOOLS = [
|
||||
{
|
||||
"name": "submit_matches",
|
||||
"description": "Submit the best GMAL matches for a client asset.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"matches": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"gmal_id": {
|
||||
"type": "string",
|
||||
"description": "The GMAL ID of the matched asset (e.g., 'GMAL101')"
|
||||
},
|
||||
"confidence": {
|
||||
"type": "string",
|
||||
"enum": ["exact", "close", "multiple", "none"],
|
||||
"description": "exact=direct match, close=similar but differences exist, multiple=one of several candidates, none=no reasonable match"
|
||||
},
|
||||
"confidence_score": {
|
||||
"type": "number",
|
||||
"description": "Confidence score from 0.0 to 1.0"
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "string",
|
||||
"description": "Why this GMAL was matched - what makes it similar"
|
||||
},
|
||||
"caveats": {
|
||||
"type": "string",
|
||||
"description": "Important differences between what the client asked for and what the GMAL asset covers. Include scope gaps, complexity mismatches, format differences."
|
||||
},
|
||||
},
|
||||
"required": ["gmal_id", "confidence", "confidence_score", "reasoning", "caveats"],
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 3,
|
||||
},
|
||||
},
|
||||
"required": ["matches"],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
SYSTEM_PROMPT = """You are a GMAL asset matching specialist for a creative production agency.
|
||||
|
||||
Your job is to match client-described assets/deliverables to the closest equivalent(s) in the GMAL catalog.
|
||||
|
||||
The GMAL catalog is a standardized list of creative production assets, each with:
|
||||
- A unique GMAL ID (e.g., GMAL101)
|
||||
- Asset name and description
|
||||
- Complexity level (Simple=1, Medium=2, Complex=3)
|
||||
- Detailed complexity description
|
||||
|
||||
Guidelines:
|
||||
- Match based on the TYPE of deliverable first, then complexity level.
|
||||
- Consider that clients may use different terminology (e.g., "banner" vs "web banner", "copywriting" vs "editorial").
|
||||
- If the client asset maps clearly to one GMAL, set confidence="exact" with score 0.9-1.0.
|
||||
- If similar but with notable differences, set confidence="close" with score 0.6-0.89.
|
||||
- If multiple GMALs could match, return up to 3 ranked options with confidence="multiple".
|
||||
- If nothing matches well, return the closest option with confidence="none" and score below 0.3.
|
||||
- Always explain caveats: what the GMAL includes/excludes vs what the client described.
|
||||
- Pay attention to complexity: a "simple banner" should match a Simple complexity GMAL, not Complex."""
|
||||
|
||||
|
||||
def _match_single_asset(client_asset_name, client_asset_desc, volume, candidates_text, num_candidates):
|
||||
"""Run a single match call to Claude (synchronous, for use in thread pool)."""
|
||||
user_msg = f"""Match this client asset to the best GMAL equivalent(s):
|
||||
|
||||
CLIENT ASSET:
|
||||
Name: {client_asset_name}
|
||||
Description: {client_asset_desc or 'No description provided'}
|
||||
Volume: {volume}
|
||||
|
||||
GMAL CATALOG CANDIDATES ({num_candidates} assets):
|
||||
{candidates_text}"""
|
||||
|
||||
response = call_claude(
|
||||
system=SYSTEM_PROMPT,
|
||||
user_message=user_msg,
|
||||
tools=MATCH_TOOLS,
|
||||
tool_choice={"type": "tool", "name": "submit_matches"},
|
||||
max_tokens=2048,
|
||||
)
|
||||
usage = getattr(response, '_usage_info', {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0})
|
||||
return extract_tool_result(response), usage
|
||||
|
||||
|
||||
async def match_client_assets(
|
||||
db: AsyncSession,
|
||||
project_id: int,
|
||||
client_assets: list[ClientAsset],
|
||||
) -> list[Match]:
|
||||
"""Match all client assets against the GMAL catalog.
|
||||
|
||||
Runs in parallel batches of BATCH_SIZE. Commits after each batch
|
||||
so the frontend can poll for progress. Supports cancellation.
|
||||
"""
|
||||
_clear_cancel(project_id)
|
||||
|
||||
# Load all GMAL assets for candidate selection
|
||||
result = await db.execute(
|
||||
select(GmalAsset).where(GmalAsset.has_hour_routes == True)
|
||||
)
|
||||
all_gmals = result.scalars().all()
|
||||
gmal_by_id = {g.gmal_id: g for g in all_gmals}
|
||||
|
||||
all_matches = []
|
||||
total = len(client_assets)
|
||||
|
||||
# Process in batches
|
||||
for batch_start in range(0, total, BATCH_SIZE):
|
||||
if _is_cancelled(project_id):
|
||||
logger.info(f"Matching cancelled for project {project_id} at {batch_start}/{total}")
|
||||
break
|
||||
|
||||
batch = client_assets[batch_start:batch_start + BATCH_SIZE]
|
||||
batch_num = batch_start // BATCH_SIZE + 1
|
||||
logger.info(f"Matching batch {batch_num} ({batch_start+1}-{min(batch_start+BATCH_SIZE, total)} of {total})")
|
||||
|
||||
# Prepare all calls for this batch
|
||||
call_args = []
|
||||
for ca in batch:
|
||||
candidates = _prefilter_candidates(ca, all_gmals)
|
||||
candidates_text = _format_candidates(candidates)
|
||||
call_args.append((ca, candidates, candidates_text))
|
||||
|
||||
# Run batch in parallel using thread pool
|
||||
loop = asyncio.get_event_loop()
|
||||
with ThreadPoolExecutor(max_workers=BATCH_SIZE) as executor:
|
||||
futures = []
|
||||
for ca, candidates, candidates_text in call_args:
|
||||
if _is_cancelled(project_id):
|
||||
break
|
||||
future = loop.run_in_executor(
|
||||
executor,
|
||||
_match_single_asset,
|
||||
ca.raw_name,
|
||||
ca.raw_description,
|
||||
ca.volume,
|
||||
candidates_text,
|
||||
len(candidates),
|
||||
)
|
||||
futures.append((ca, future))
|
||||
|
||||
# Collect results and accumulate costs
|
||||
batch_input = 0
|
||||
batch_output = 0
|
||||
batch_cost = 0.0
|
||||
|
||||
for ca, future in futures:
|
||||
try:
|
||||
tool_result, usage = await future
|
||||
batch_input += usage.get("input_tokens", 0)
|
||||
batch_output += usage.get("output_tokens", 0)
|
||||
batch_cost += usage.get("cost_usd", 0)
|
||||
|
||||
if tool_result and "matches" in tool_result:
|
||||
# Auto-select: if top match is >= 80%, select it
|
||||
top_score = tool_result["matches"][0].get("confidence_score", 0) if tool_result["matches"] else 0
|
||||
auto_select = top_score >= 0.8
|
||||
|
||||
for rank, m in enumerate(tool_result["matches"], 1):
|
||||
gmal = gmal_by_id.get(m["gmal_id"])
|
||||
if not gmal:
|
||||
logger.warning(f"Claude returned unknown GMAL ID: {m['gmal_id']}")
|
||||
continue
|
||||
|
||||
match = Match(
|
||||
client_asset_id=ca.id,
|
||||
gmal_asset_id=gmal.id,
|
||||
confidence=MatchConfidence(m["confidence"]),
|
||||
confidence_score=m.get("confidence_score"),
|
||||
ai_reasoning=m.get("reasoning"),
|
||||
caveat_text=m.get("caveats"),
|
||||
is_selected=(rank == 1 and auto_select),
|
||||
rank=rank,
|
||||
)
|
||||
db.add(match)
|
||||
all_matches.append(match)
|
||||
else:
|
||||
logger.warning(f"No match result for: {ca.raw_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error matching '{ca.raw_name}': {e}")
|
||||
|
||||
# Save batch costs to project
|
||||
from app.models.project import Project
|
||||
proj_result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = proj_result.scalar_one_or_none()
|
||||
if project:
|
||||
project.ai_input_tokens = (project.ai_input_tokens or 0) + batch_input
|
||||
project.ai_output_tokens = (project.ai_output_tokens or 0) + batch_output
|
||||
project.ai_cost_usd = float(project.ai_cost_usd or 0) + batch_cost
|
||||
project.ai_call_count = (project.ai_call_count or 0) + len(batch)
|
||||
|
||||
# Commit after each batch so frontend can see progress
|
||||
await db.commit()
|
||||
logger.info(f"Batch {batch_num} committed, {len(all_matches)} total matches so far")
|
||||
|
||||
_clear_cancel(project_id)
|
||||
return all_matches
|
||||
|
||||
|
||||
def _prefilter_candidates(client_asset: ClientAsset, all_gmals: list[GmalAsset], max_candidates: int = 25) -> list[GmalAsset]:
|
||||
"""Pre-filter GMAL candidates using keyword overlap to reduce token usage."""
|
||||
name = (client_asset.raw_name or "").lower()
|
||||
desc = (client_asset.raw_description or "").lower()
|
||||
search_text = f"{name} {desc}"
|
||||
|
||||
stop_words = {"the", "a", "an", "and", "or", "for", "to", "in", "of", "with", "is", "on", "at", "by"}
|
||||
keywords = set(search_text.split()) - stop_words
|
||||
|
||||
scored = []
|
||||
for gmal in all_gmals:
|
||||
gmal_text = " ".join(filter(None, [
|
||||
gmal.asset_name,
|
||||
gmal.sub_category,
|
||||
gmal.unique_name,
|
||||
gmal.complexity_description,
|
||||
gmal.ai_enhanced_description,
|
||||
])).lower()
|
||||
|
||||
score = sum(1 for kw in keywords if kw in gmal_text)
|
||||
|
||||
if gmal.asset_name and any(word in gmal.asset_name.lower() for word in name.split() if len(word) > 3):
|
||||
score += 5
|
||||
|
||||
scored.append((score, gmal))
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
candidates = [g for _, g in scored[:max_candidates]]
|
||||
|
||||
if len([s for s, _ in scored[:max_candidates] if s > 0]) < 5:
|
||||
seen_cats = set()
|
||||
for _, g in scored:
|
||||
if g.sub_category not in seen_cats and g not in candidates:
|
||||
candidates.append(g)
|
||||
seen_cats.add(g.sub_category)
|
||||
if len(candidates) >= max_candidates:
|
||||
break
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def _format_candidates(candidates: list[GmalAsset]) -> str:
|
||||
"""Format GMAL candidates as text for the Claude prompt."""
|
||||
parts = []
|
||||
for g in candidates:
|
||||
desc = g.ai_enhanced_description or g.complexity_description or g.asset_description or ""
|
||||
if len(desc) > 300:
|
||||
desc = desc[:300] + "..."
|
||||
|
||||
parts.append(
|
||||
f"- {g.gmal_id}: {g.unique_name or g.asset_name} "
|
||||
f"(Complexity: {g.complexity_name or g.complexity_level}, "
|
||||
f"Category: {g.sub_category})\n"
|
||||
f" Description: {desc}"
|
||||
)
|
||||
|
||||
return "\n\n".join(parts)
|
||||
136
backend/app/services/doc_parser.py
Normal file
136
backend/app/services/doc_parser.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"""Parse uploaded client documents (Word/Excel) to extract asset lists."""
|
||||
|
||||
import logging
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
import docx
|
||||
|
||||
from app.utils.claude_client import call_claude, extract_tool_result, extract_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EXTRACT_TOOLS = [
|
||||
{
|
||||
"name": "extract_assets",
|
||||
"description": "Extract a structured list of deliverable assets from a client brief or scope document.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"assets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The asset/deliverable name as described by the client"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Description of what this asset involves, including any complexity or format details"
|
||||
},
|
||||
"complexity_hint": {
|
||||
"type": "string",
|
||||
"enum": ["simple", "medium", "complex", "unknown"],
|
||||
"description": "Estimated complexity based on the brief"
|
||||
},
|
||||
"volume": {
|
||||
"type": "integer",
|
||||
"description": "Number of this asset needed (default 1 if not specified)"
|
||||
},
|
||||
},
|
||||
"required": ["name", "description", "complexity_hint", "volume"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["assets"],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
SYSTEM_PROMPT = """You are a creative agency asset specialist who understands production scoping.
|
||||
Your job is to extract every distinct deliverable/asset from the client brief or scope document provided.
|
||||
|
||||
For each asset, provide:
|
||||
- name: The asset name as the client describes it (e.g., "Social Media Banner", "TV Commercial Edit", "Brand Book")
|
||||
- description: What this asset involves based on the document context. Include format, size, channel, and any other relevant details.
|
||||
- complexity_hint: Your best estimate of complexity (simple/medium/complex) based on the description. Use "unknown" if unclear.
|
||||
- volume: How many of this asset are needed. Default to 1 if not specified.
|
||||
|
||||
Be thorough - extract every distinct asset type mentioned. If the same asset appears at different complexity levels, list them separately.
|
||||
Do NOT combine different asset types into one entry."""
|
||||
|
||||
|
||||
def parse_uploaded_file(file_content: bytes, filename: str) -> list[dict]:
|
||||
"""Parse a client document and extract assets using Claude.
|
||||
|
||||
Returns a list of dicts: [{name, description, complexity_hint, volume}, ...]
|
||||
"""
|
||||
ext = Path(filename).suffix.lower()
|
||||
|
||||
if ext == ".docx":
|
||||
text = _extract_docx_text(file_content)
|
||||
elif ext in (".xlsx", ".xls"):
|
||||
text = _extract_excel_text(file_content)
|
||||
elif ext == ".txt":
|
||||
text = file_content.decode("utf-8", errors="replace")
|
||||
else:
|
||||
raise ValueError(f"Unsupported file type: {ext}. Use .docx, .xlsx, or .txt")
|
||||
|
||||
if not text or len(text.strip()) < 20:
|
||||
raise ValueError("Document appears to be empty or too short to extract assets from.")
|
||||
|
||||
# Truncate very long documents to manage token usage
|
||||
if len(text) > 50000:
|
||||
text = text[:50000] + "\n\n[Document truncated...]"
|
||||
|
||||
response = call_claude(
|
||||
system=SYSTEM_PROMPT,
|
||||
user_message=f"Extract all deliverable assets from this client document:\n\n{text}",
|
||||
tools=EXTRACT_TOOLS,
|
||||
tool_choice={"type": "tool", "name": "extract_assets"},
|
||||
max_tokens=16000,
|
||||
)
|
||||
|
||||
# Extract usage info for per-project tracking
|
||||
usage_info = getattr(response, '_usage_info', {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0})
|
||||
|
||||
result = extract_tool_result(response)
|
||||
if not result or "assets" not in result:
|
||||
logger.warning("Claude did not return structured asset data, response: %s", extract_text(response))
|
||||
return [], usage_info
|
||||
|
||||
return result["assets"], usage_info
|
||||
|
||||
|
||||
def _extract_docx_text(content: bytes) -> str:
|
||||
"""Extract text from a .docx file."""
|
||||
doc = docx.Document(io.BytesIO(content))
|
||||
paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
|
||||
|
||||
# Also extract text from tables
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
cells = [cell.text.strip() for cell in row.cells if cell.text.strip()]
|
||||
if cells:
|
||||
paragraphs.append(" | ".join(cells))
|
||||
|
||||
return "\n".join(paragraphs)
|
||||
|
||||
|
||||
def _extract_excel_text(content: bytes) -> str:
|
||||
"""Extract text from an Excel file, converting all sheets to text."""
|
||||
wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True)
|
||||
parts = []
|
||||
|
||||
for sheet_name in wb.sheetnames:
|
||||
ws = wb[sheet_name]
|
||||
parts.append(f"\n=== Sheet: {sheet_name} ===")
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
cells = [str(c) for c in row if c is not None]
|
||||
if cells:
|
||||
parts.append(" | ".join(cells))
|
||||
|
||||
return "\n".join(parts)
|
||||
336
backend/app/services/excel_parser.py
Normal file
336
backend/app/services/excel_parser.py
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
"""Parse the GMAL Excel workbook into database records."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.gmal import GmalAsset, Role, GmalHours, GmalServiceLine, RoleLevelMapping, MODEL_TYPE_MAP, ModelType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROGRAMME_ROLE_KEYWORDS = [
|
||||
"programme director", "global programme director",
|
||||
"regional / national operations director",
|
||||
"head of creative services", "head of project management",
|
||||
"creative services director",
|
||||
]
|
||||
|
||||
|
||||
def parse_gmal_workbook(filepath: str, db: Session) -> dict:
|
||||
"""Parse the full GMAL Excel workbook and load all data into the database.
|
||||
|
||||
Returns a dict with counts of loaded records.
|
||||
"""
|
||||
wb = openpyxl.load_workbook(filepath, data_only=True)
|
||||
result = {
|
||||
"assets_loaded": 0,
|
||||
"roles_loaded": 0,
|
||||
"hours_loaded": 0,
|
||||
"service_lines_loaded": 0,
|
||||
"role_mappings_loaded": 0,
|
||||
}
|
||||
|
||||
# Clear existing data (full re-ingest)
|
||||
db.execute(text("DELETE FROM gmal_hours"))
|
||||
db.execute(text("DELETE FROM gmal_service_lines"))
|
||||
db.execute(text("DELETE FROM role_level_mappings"))
|
||||
db.execute(text("DELETE FROM roles"))
|
||||
db.execute(text("DELETE FROM ratecard_lines"))
|
||||
db.execute(text("DELETE FROM matches"))
|
||||
db.execute(text("DELETE FROM client_assets"))
|
||||
db.execute(text("DELETE FROM projects"))
|
||||
db.execute(text("DELETE FROM gmal_assets"))
|
||||
db.flush()
|
||||
|
||||
# Step 1: Load asset catalog from "Unilever_GMAL Asset List"
|
||||
asset_map = _load_asset_list(wb, db)
|
||||
result["assets_loaded"] = len(asset_map)
|
||||
|
||||
# Step 2: Load roles and hours from "GMAL Routes_Jan25"
|
||||
roles_loaded, hours_loaded, extra_assets = _load_routes(wb, db, asset_map)
|
||||
result["roles_loaded"] = roles_loaded
|
||||
result["hours_loaded"] = hours_loaded
|
||||
result["assets_loaded"] += extra_assets
|
||||
|
||||
# Step 3: Load service lines
|
||||
result["service_lines_loaded"] = _load_service_lines(wb, db)
|
||||
|
||||
# Step 4: Load role-level mappings
|
||||
result["role_mappings_loaded"] = _load_role_mappings(wb, db)
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Ingestion complete: {result}")
|
||||
return result
|
||||
|
||||
|
||||
def _load_asset_list(wb: openpyxl.Workbook, db: Session) -> dict[str, GmalAsset]:
|
||||
"""Load from 'Unilever_GMAL Asset List' sheet. Returns {gmal_id: GmalAsset}."""
|
||||
ws = wb["Unilever_GMAL Asset List"]
|
||||
asset_map = {}
|
||||
|
||||
for row_idx in range(2, ws.max_row + 1):
|
||||
gmal_id = ws.cell(row=row_idx, column=2).value
|
||||
if not gmal_id:
|
||||
continue
|
||||
gmal_id = str(gmal_id).strip()
|
||||
|
||||
complexity_level = ws.cell(row=row_idx, column=8).value
|
||||
complexity_name_val = ws.cell(row=row_idx, column=9).value
|
||||
|
||||
asset = GmalAsset(
|
||||
gmal_id=gmal_id,
|
||||
swop_asset_id=_str(ws.cell(row=row_idx, column=3).value),
|
||||
region=_str(ws.cell(row=row_idx, column=1).value),
|
||||
category=_str(ws.cell(row=row_idx, column=4).value),
|
||||
sub_category=_str(ws.cell(row=row_idx, column=5).value),
|
||||
sub_category_description=_str(ws.cell(row=row_idx, column=6).value),
|
||||
asset_name=_str(ws.cell(row=row_idx, column=7).value),
|
||||
complexity_level=int(complexity_level) if complexity_level else None,
|
||||
complexity_name=_str(complexity_name_val),
|
||||
unique_name=_str(ws.cell(row=row_idx, column=10).value),
|
||||
asset_description=_str(ws.cell(row=row_idx, column=11).value),
|
||||
complexity_description=_str(ws.cell(row=row_idx, column=12).value),
|
||||
caveats=_str(ws.cell(row=row_idx, column=13).value),
|
||||
has_hour_routes=False,
|
||||
)
|
||||
db.add(asset)
|
||||
asset_map[gmal_id] = asset
|
||||
|
||||
db.flush()
|
||||
logger.info(f"Loaded {len(asset_map)} assets from Asset List")
|
||||
return asset_map
|
||||
|
||||
|
||||
def _load_routes(wb: openpyxl.Workbook, db: Session, asset_map: dict[str, GmalAsset]) -> tuple[int, int, int]:
|
||||
"""Load roles and hours from 'GMAL Routes_Jan25' sheet.
|
||||
|
||||
Returns (roles_loaded, hours_loaded, extra_assets_created).
|
||||
"""
|
||||
ws = wb["GMAL Routes_Jan25"]
|
||||
|
||||
# Detect model type column boundaries from row 1
|
||||
model_sections = [] # [(start_col, end_col, ModelType)]
|
||||
current_header = None
|
||||
section_start = None
|
||||
|
||||
for col_idx in range(13, ws.max_column + 1):
|
||||
header = ws.cell(row=1, column=col_idx).value
|
||||
if header and header != current_header:
|
||||
if current_header and section_start:
|
||||
mt = MODEL_TYPE_MAP.get(current_header)
|
||||
if mt:
|
||||
model_sections.append((section_start, col_idx - 1, mt))
|
||||
current_header = header
|
||||
section_start = col_idx
|
||||
|
||||
# Don't forget the last section
|
||||
if current_header and section_start:
|
||||
mt = MODEL_TYPE_MAP.get(current_header)
|
||||
if mt:
|
||||
model_sections.append((section_start, ws.max_column, mt))
|
||||
|
||||
logger.info(f"Detected {len(model_sections)} model type sections")
|
||||
for start, end, mt in model_sections:
|
||||
logger.info(f" {mt.value}: cols {start}-{end}")
|
||||
|
||||
# Load roles from rows 13-137 (column B=discipline, C=title, D=entity, E=location, F=unique)
|
||||
# Map row_idx -> Role for hour lookups; use unique_name as dedup key
|
||||
roles = {} # {(role_title, entity): Role}
|
||||
row_to_role = {} # {row_idx: Role} for hour lookups
|
||||
for row_idx in range(13, 138):
|
||||
discipline = ws.cell(row=row_idx, column=2).value
|
||||
role_title = ws.cell(row=row_idx, column=3).value
|
||||
if not role_title:
|
||||
continue
|
||||
|
||||
entity = _str(ws.cell(row=row_idx, column=4).value)
|
||||
location = _str(ws.cell(row=row_idx, column=5).value)
|
||||
unique_name = _str(ws.cell(row=row_idx, column=6).value)
|
||||
key = (str(role_title).strip(), entity)
|
||||
|
||||
# Skip duplicates - reuse existing role for hour lookups
|
||||
if key in roles:
|
||||
row_to_role[row_idx] = roles[key]
|
||||
continue
|
||||
|
||||
is_prog = any(kw in str(role_title).lower() for kw in PROGRAMME_ROLE_KEYWORDS)
|
||||
|
||||
role = Role(
|
||||
discipline=_str(discipline) or "Other",
|
||||
role_title=_str(role_title),
|
||||
entity=entity,
|
||||
resource_location=location,
|
||||
unique_name=unique_name,
|
||||
sort_order=row_idx - 12,
|
||||
is_programme_role=is_prog,
|
||||
)
|
||||
db.add(role)
|
||||
roles[key] = role
|
||||
row_to_role[row_idx] = role
|
||||
|
||||
db.flush()
|
||||
logger.info(f"Loaded {len(roles)} roles")
|
||||
|
||||
# For each model type section, build a mapping of column -> gmal_id
|
||||
# Then load hours for each (role, gmal, model_type) combo
|
||||
hours_count = 0
|
||||
extra_assets = 0
|
||||
|
||||
# Track which (gmal_id, role_id, model_type) combos we've already inserted
|
||||
# to handle duplicate columns (Pro vs non-Pro) for same GMAL within a section
|
||||
seen_hours = set()
|
||||
|
||||
for section_start, section_end, model_type in model_sections:
|
||||
# Build column -> gmal_id map for this section
|
||||
# Include Pro variant columns - they contain the actual AI model data
|
||||
# Extract the base GMAL ID from row 2 (e.g. "GMAL101" from both base and Pro cols)
|
||||
col_gmal_map = {}
|
||||
for col_idx in range(section_start, section_end + 1):
|
||||
gmal_id = ws.cell(row=2, column=col_idx).value
|
||||
if gmal_id and str(gmal_id).startswith("GMAL"):
|
||||
base_id = str(gmal_id).strip()
|
||||
col_gmal_map[col_idx] = base_id
|
||||
|
||||
# Enrich asset_map with metadata from Routes if not in Asset List
|
||||
for col_idx, gmal_id in col_gmal_map.items():
|
||||
if gmal_id not in asset_map:
|
||||
# Create asset from Routes metadata
|
||||
asset = GmalAsset(
|
||||
gmal_id=gmal_id,
|
||||
sub_category=_str(ws.cell(row=4, column=col_idx).value),
|
||||
asset_name=_str(ws.cell(row=5, column=col_idx).value),
|
||||
asset_description=_str(ws.cell(row=6, column=col_idx).value),
|
||||
complexity_description=_str(ws.cell(row=7, column=col_idx).value),
|
||||
caveats=_str(ws.cell(row=8, column=col_idx).value),
|
||||
complexity_level=_int(ws.cell(row=9, column=col_idx).value),
|
||||
master_adapt=_str(ws.cell(row=10, column=col_idx).value),
|
||||
ai_efficiency_pct=_float(ws.cell(row=11, column=col_idx).value),
|
||||
has_hour_routes=True,
|
||||
)
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
asset_map[gmal_id] = asset
|
||||
extra_assets += 1
|
||||
else:
|
||||
# Update Routes-specific fields on existing asset
|
||||
asset = asset_map[gmal_id]
|
||||
asset.has_hour_routes = True
|
||||
if not asset.master_adapt:
|
||||
asset.master_adapt = _str(ws.cell(row=10, column=col_idx).value)
|
||||
if not asset.ai_efficiency_pct:
|
||||
asset.ai_efficiency_pct = _float(ws.cell(row=11, column=col_idx).value)
|
||||
if not asset.sub_category:
|
||||
asset.sub_category = _str(ws.cell(row=4, column=col_idx).value)
|
||||
|
||||
# Now load hours for each role x gmal in this section
|
||||
for row_idx in range(13, 138):
|
||||
role = row_to_role.get(row_idx)
|
||||
if not role:
|
||||
continue
|
||||
|
||||
for col_idx, gmal_id in col_gmal_map.items():
|
||||
hours_val = ws.cell(row=row_idx, column=col_idx).value
|
||||
if hours_val and _float(hours_val) and _float(hours_val) != 0:
|
||||
asset = asset_map.get(gmal_id)
|
||||
if not asset:
|
||||
continue
|
||||
|
||||
# Deduplicate: skip if we already have hours for this combo
|
||||
key = (asset.id, role.id, model_type)
|
||||
if key in seen_hours:
|
||||
continue
|
||||
seen_hours.add(key)
|
||||
|
||||
gh = GmalHours(
|
||||
gmal_asset_id=asset.id,
|
||||
role_id=role.id,
|
||||
model_type=model_type,
|
||||
hours=round(_float(hours_val), 2),
|
||||
)
|
||||
db.add(gh)
|
||||
hours_count += 1
|
||||
|
||||
# Flush every 5000 to manage memory
|
||||
if hours_count % 5000 == 0:
|
||||
db.flush()
|
||||
|
||||
db.flush()
|
||||
logger.info(f"Loaded {hours_count} hour records across {len(model_sections)} model types")
|
||||
return len(roles), hours_count, extra_assets
|
||||
|
||||
|
||||
def _load_service_lines(wb: openpyxl.Workbook, db: Session) -> int:
|
||||
"""Load from 'GMAL SERVICE LINES' sheet."""
|
||||
ws = wb["GMAL SERVICE LINES"]
|
||||
count = 0
|
||||
|
||||
for row_idx in range(2, ws.max_row + 1):
|
||||
number = ws.cell(row=row_idx, column=1).value
|
||||
name = ws.cell(row=row_idx, column=2).value
|
||||
if not name:
|
||||
continue
|
||||
|
||||
sl = GmalServiceLine(
|
||||
number=_str(number),
|
||||
name=_str(name),
|
||||
type=_str(ws.cell(row=row_idx, column=3).value),
|
||||
gmal_id=_str(ws.cell(row=row_idx, column=4).value),
|
||||
)
|
||||
db.add(sl)
|
||||
count += 1
|
||||
|
||||
db.flush()
|
||||
logger.info(f"Loaded {count} service lines")
|
||||
return count
|
||||
|
||||
|
||||
def _load_role_mappings(wb: openpyxl.Workbook, db: Session) -> int:
|
||||
"""Load from 'Job role to Level mapping' sheet."""
|
||||
ws = wb["Job role to Level mapping"]
|
||||
count = 0
|
||||
|
||||
for row_idx in range(3, ws.max_row + 1):
|
||||
role_name = ws.cell(row=row_idx, column=1).value
|
||||
if not role_name:
|
||||
continue
|
||||
|
||||
rlm = RoleLevelMapping(
|
||||
role_name=_str(role_name),
|
||||
number=_str(ws.cell(row=row_idx, column=3).value),
|
||||
level_name=_str(ws.cell(row=row_idx, column=4).value),
|
||||
type=_str(ws.cell(row=row_idx, column=5).value),
|
||||
)
|
||||
db.add(rlm)
|
||||
count += 1
|
||||
|
||||
db.flush()
|
||||
logger.info(f"Loaded {count} role-level mappings")
|
||||
return count
|
||||
|
||||
|
||||
def _str(val) -> str | None:
|
||||
if val is None:
|
||||
return None
|
||||
s = str(val).strip()
|
||||
return s if s else None
|
||||
|
||||
|
||||
def _int(val) -> int | None:
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _float(val) -> float | None:
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
return float(val)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
217
backend/app/services/export_excel.py
Normal file
217
backend/app/services/export_excel.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"""Export ratecard data to Excel."""
|
||||
|
||||
import io
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.gmal import GmalAsset, Role
|
||||
from app.models.project import Project, ClientAsset, Match, RatecardLine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HEADER_FILL = PatternFill(start_color="1F4E79", end_color="1F4E79", fill_type="solid")
|
||||
HEADER_FONT = Font(color="FFFFFF", bold=True, size=11)
|
||||
DISCIPLINE_FILL = PatternFill(start_color="D6E4F0", end_color="D6E4F0", fill_type="solid")
|
||||
THIN_BORDER = Border(
|
||||
left=Side(style="thin"),
|
||||
right=Side(style="thin"),
|
||||
top=Side(style="thin"),
|
||||
bottom=Side(style="thin"),
|
||||
)
|
||||
|
||||
|
||||
async def export_ratecard_excel(db: AsyncSession, project: Project) -> bytes:
|
||||
"""Generate an Excel workbook with the ratecard data.
|
||||
|
||||
Returns the workbook as bytes.
|
||||
"""
|
||||
wb = Workbook()
|
||||
|
||||
# Load all data
|
||||
lines_result = await db.execute(
|
||||
select(RatecardLine).where(RatecardLine.project_id == project.id)
|
||||
)
|
||||
lines = lines_result.scalars().all()
|
||||
|
||||
if not lines:
|
||||
ws = wb.active
|
||||
ws.title = "Ratecard"
|
||||
ws["A1"] = "No ratecard data available"
|
||||
return _workbook_to_bytes(wb)
|
||||
|
||||
# Load related entities
|
||||
role_ids = list(set(l.role_id for l in lines))
|
||||
asset_ids = list(set(l.client_asset_id for l in lines))
|
||||
gmal_ids = list(set(l.gmal_asset_id for l in lines))
|
||||
|
||||
roles_result = await db.execute(select(Role).where(Role.id.in_(role_ids)))
|
||||
roles = {r.id: r for r in roles_result.scalars().all()}
|
||||
|
||||
assets_result = await db.execute(select(ClientAsset).where(ClientAsset.id.in_(asset_ids)))
|
||||
client_assets = {a.id: a for a in assets_result.scalars().all()}
|
||||
|
||||
gmals_result = await db.execute(select(GmalAsset).where(GmalAsset.id.in_(gmal_ids)))
|
||||
gmals = {g.id: g for g in gmals_result.scalars().all()}
|
||||
|
||||
# Sheet 1: Ratecard Summary (roles x assets matrix)
|
||||
ws1 = wb.active
|
||||
ws1.title = "Ratecard Summary"
|
||||
_build_ratecard_sheet(ws1, lines, roles, client_assets, gmals)
|
||||
|
||||
# Sheet 2: Asset Detail
|
||||
ws2 = wb.create_sheet("Asset Detail")
|
||||
await _build_asset_detail_sheet(ws2, db, project, client_assets, gmals)
|
||||
|
||||
return _workbook_to_bytes(wb)
|
||||
|
||||
|
||||
def _build_ratecard_sheet(ws, lines, roles, client_assets, gmals):
|
||||
"""Build the main ratecard matrix: rows=roles, cols=client assets."""
|
||||
# Get unique sorted client assets and roles
|
||||
asset_ids_ordered = sorted(client_assets.keys())
|
||||
role_ids_ordered = sorted(roles.keys(), key=lambda rid: (roles[rid].discipline, roles[rid].sort_order or 0))
|
||||
|
||||
# Build hours lookup: {(role_id, client_asset_id): total_hours}
|
||||
hours_map = {}
|
||||
for line in lines:
|
||||
effective_hours = line.manual_override if line.manual_override is not None else line.total_hours
|
||||
hours_map[(line.role_id, line.client_asset_id)] = float(effective_hours or 0)
|
||||
|
||||
# Headers
|
||||
ws.cell(row=1, column=1, value="Discipline").font = HEADER_FONT
|
||||
ws.cell(row=1, column=1).fill = HEADER_FILL
|
||||
ws.cell(row=1, column=2, value="Role").font = HEADER_FONT
|
||||
ws.cell(row=1, column=2).fill = HEADER_FILL
|
||||
|
||||
for col_idx, asset_id in enumerate(asset_ids_ordered, 3):
|
||||
ca = client_assets[asset_id]
|
||||
gmal_id = None
|
||||
for line in lines:
|
||||
if line.client_asset_id == asset_id:
|
||||
g = gmals.get(line.gmal_asset_id)
|
||||
gmal_id = g.gmal_id if g else None
|
||||
break
|
||||
|
||||
header = f"{ca.raw_name}\n(Vol: {ca.volume})"
|
||||
if gmal_id:
|
||||
header += f"\n[{gmal_id}]"
|
||||
|
||||
cell = ws.cell(row=1, column=col_idx, value=header)
|
||||
cell.font = HEADER_FONT
|
||||
cell.fill = HEADER_FILL
|
||||
cell.alignment = Alignment(wrap_text=True, horizontal="center")
|
||||
|
||||
# Total column
|
||||
total_col = len(asset_ids_ordered) + 3
|
||||
ws.cell(row=1, column=total_col, value="Total Hours").font = HEADER_FONT
|
||||
ws.cell(row=1, column=total_col).fill = HEADER_FILL
|
||||
|
||||
# Data rows
|
||||
current_discipline = None
|
||||
row_idx = 2
|
||||
|
||||
for role_id in role_ids_ordered:
|
||||
role = roles[role_id]
|
||||
|
||||
# Check if this role has any hours at all
|
||||
role_total = sum(hours_map.get((role_id, aid), 0) for aid in asset_ids_ordered)
|
||||
if role_total == 0:
|
||||
continue
|
||||
|
||||
# Discipline grouping
|
||||
if role.discipline != current_discipline:
|
||||
current_discipline = role.discipline
|
||||
ws.cell(row=row_idx, column=1, value=current_discipline).font = Font(bold=True)
|
||||
ws.cell(row=row_idx, column=1).fill = DISCIPLINE_FILL
|
||||
for c in range(1, total_col + 1):
|
||||
ws.cell(row=row_idx, column=c).fill = DISCIPLINE_FILL
|
||||
row_idx += 1
|
||||
|
||||
ws.cell(row=row_idx, column=1, value=role.discipline)
|
||||
ws.cell(row=row_idx, column=2, value=role.role_title)
|
||||
|
||||
row_total = 0
|
||||
for col_idx, asset_id in enumerate(asset_ids_ordered, 3):
|
||||
hours = hours_map.get((role_id, asset_id), 0)
|
||||
if hours > 0:
|
||||
ws.cell(row=row_idx, column=col_idx, value=round(hours, 2))
|
||||
row_total += hours
|
||||
|
||||
ws.cell(row=row_idx, column=total_col, value=round(row_total, 2)).font = Font(bold=True)
|
||||
row_idx += 1
|
||||
|
||||
# Grand total row
|
||||
row_idx += 1
|
||||
ws.cell(row=row_idx, column=1, value="TOTAL").font = Font(bold=True, size=12)
|
||||
grand_total = 0
|
||||
for col_idx, asset_id in enumerate(asset_ids_ordered, 3):
|
||||
col_total = sum(hours_map.get((rid, asset_id), 0) for rid in role_ids_ordered)
|
||||
if col_total > 0:
|
||||
ws.cell(row=row_idx, column=col_idx, value=round(col_total, 2)).font = Font(bold=True)
|
||||
grand_total += col_total
|
||||
ws.cell(row=row_idx, column=total_col, value=round(grand_total, 2)).font = Font(bold=True, size=12)
|
||||
|
||||
# Column widths
|
||||
ws.column_dimensions["A"].width = 25
|
||||
ws.column_dimensions["B"].width = 35
|
||||
for col_idx in range(3, total_col + 1):
|
||||
ws.column_dimensions[get_column_letter(col_idx)].width = 18
|
||||
|
||||
|
||||
async def _build_asset_detail_sheet(ws, db, project, client_assets, gmals):
|
||||
"""Build the asset detail sheet showing matches and caveats."""
|
||||
headers = ["Client Asset", "Volume", "Matched GMAL", "GMAL Name", "Confidence", "Score", "Caveats"]
|
||||
for col_idx, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col_idx, value=header)
|
||||
cell.font = HEADER_FONT
|
||||
cell.fill = HEADER_FILL
|
||||
|
||||
# Load matches
|
||||
from app.models.project import Match
|
||||
matches_result = await db.execute(
|
||||
select(Match).where(
|
||||
Match.client_asset_id.in_(list(client_assets.keys())),
|
||||
Match.is_selected == True,
|
||||
)
|
||||
)
|
||||
matches = matches_result.scalars().all()
|
||||
match_by_asset = {m.client_asset_id: m for m in matches}
|
||||
|
||||
row_idx = 2
|
||||
for asset_id in sorted(client_assets.keys()):
|
||||
ca = client_assets[asset_id]
|
||||
match = match_by_asset.get(asset_id)
|
||||
|
||||
ws.cell(row=row_idx, column=1, value=ca.raw_name)
|
||||
ws.cell(row=row_idx, column=2, value=ca.volume)
|
||||
|
||||
if match:
|
||||
gmal = gmals.get(match.gmal_asset_id)
|
||||
ws.cell(row=row_idx, column=3, value=gmal.gmal_id if gmal else "")
|
||||
ws.cell(row=row_idx, column=4, value=gmal.unique_name if gmal else "")
|
||||
ws.cell(row=row_idx, column=5, value=match.confidence.value)
|
||||
ws.cell(row=row_idx, column=6, value=float(match.confidence_score) if match.confidence_score else 0)
|
||||
ws.cell(row=row_idx, column=7, value=match.caveat_text or "")
|
||||
else:
|
||||
ws.cell(row=row_idx, column=3, value="No match")
|
||||
|
||||
row_idx += 1
|
||||
|
||||
# Column widths
|
||||
widths = [30, 10, 15, 40, 12, 10, 60]
|
||||
for i, w in enumerate(widths, 1):
|
||||
ws.column_dimensions[get_column_letter(i)].width = w
|
||||
|
||||
|
||||
def _workbook_to_bytes(wb: Workbook) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
105
backend/app/services/export_pdf.py
Normal file
105
backend/app/services/export_pdf.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""Export caveats report to PDF."""
|
||||
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.gmal import GmalAsset
|
||||
from app.models.project import Project, ClientAsset, Match
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONFIDENCE_COLORS = {
|
||||
"exact": colors.HexColor("#22c55e"),
|
||||
"close": colors.HexColor("#f59e0b"),
|
||||
"multiple": colors.HexColor("#3b82f6"),
|
||||
"none": colors.HexColor("#ef4444"),
|
||||
}
|
||||
|
||||
|
||||
async def export_caveats_pdf(db: AsyncSession, project: Project) -> bytes:
|
||||
"""Generate a PDF caveats report for a project.
|
||||
|
||||
Returns the PDF as bytes.
|
||||
"""
|
||||
buf = io.BytesIO()
|
||||
doc = SimpleDocTemplate(buf, pagesize=A4, topMargin=20 * mm, bottomMargin=20 * mm)
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
title_style = ParagraphStyle("CustomTitle", parent=styles["Title"], fontSize=18, spaceAfter=12)
|
||||
heading_style = ParagraphStyle("CustomHeading", parent=styles["Heading2"], fontSize=13, spaceAfter=6)
|
||||
body_style = styles["BodyText"]
|
||||
caveat_style = ParagraphStyle("Caveat", parent=body_style, textColor=colors.HexColor("#92400e"), leftIndent=10)
|
||||
|
||||
elements = []
|
||||
|
||||
# Title
|
||||
elements.append(Paragraph(f"Scope Builder - Caveats Report", title_style))
|
||||
elements.append(Paragraph(f"Project: {project.name}", heading_style))
|
||||
if project.client_name:
|
||||
elements.append(Paragraph(f"Client: {project.client_name}", body_style))
|
||||
elements.append(Paragraph(f"Generated: {datetime.utcnow().strftime('%d %B %Y')}", body_style))
|
||||
elements.append(Spacer(1, 12))
|
||||
|
||||
# Load client assets and matches
|
||||
assets_result = await db.execute(
|
||||
select(ClientAsset).where(ClientAsset.project_id == project.id).order_by(ClientAsset.sort_order)
|
||||
)
|
||||
client_assets = assets_result.scalars().all()
|
||||
|
||||
for ca in client_assets:
|
||||
matches_result = await db.execute(
|
||||
select(Match).where(Match.client_asset_id == ca.id).order_by(Match.rank)
|
||||
)
|
||||
matches = matches_result.scalars().all()
|
||||
|
||||
elements.append(Paragraph(f"{ca.raw_name} (Volume: {ca.volume})", heading_style))
|
||||
|
||||
if ca.raw_description:
|
||||
elements.append(Paragraph(f"<i>Client description: {ca.raw_description}</i>", body_style))
|
||||
elements.append(Spacer(1, 4))
|
||||
|
||||
if not matches:
|
||||
elements.append(Paragraph("No matches found.", caveat_style))
|
||||
elements.append(Spacer(1, 8))
|
||||
continue
|
||||
|
||||
for match in matches:
|
||||
# Load GMAL asset
|
||||
gmal_result = await db.execute(select(GmalAsset).where(GmalAsset.id == match.gmal_asset_id))
|
||||
gmal = gmal_result.scalar_one_or_none()
|
||||
if not gmal:
|
||||
continue
|
||||
|
||||
selected_marker = " [SELECTED]" if match.is_selected else ""
|
||||
conf_label = match.confidence.value.upper()
|
||||
score_pct = f"{float(match.confidence_score) * 100:.0f}%" if match.confidence_score else "N/A"
|
||||
|
||||
elements.append(Paragraph(
|
||||
f"<b>Match #{match.rank}: {gmal.gmal_id} - {gmal.unique_name or gmal.asset_name}</b>{selected_marker}",
|
||||
body_style,
|
||||
))
|
||||
elements.append(Paragraph(f"Confidence: {conf_label} ({score_pct})", body_style))
|
||||
|
||||
if match.ai_reasoning:
|
||||
elements.append(Paragraph(f"<b>Reasoning:</b> {match.ai_reasoning}", body_style))
|
||||
|
||||
if match.caveat_text:
|
||||
elements.append(Paragraph(f"<b>Caveats:</b>", body_style))
|
||||
elements.append(Paragraph(match.caveat_text, caveat_style))
|
||||
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
elements.append(Spacer(1, 12))
|
||||
|
||||
doc.build(elements)
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
76
backend/app/services/ratecard_builder.py
Normal file
76
backend/app/services/ratecard_builder.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Build ratecards from confirmed matches."""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.gmal import GmalHours, ModelType
|
||||
from app.models.project import Project, ClientAsset, Match, RatecardLine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def build_ratecard(db: AsyncSession, project: Project) -> list[RatecardLine]:
|
||||
"""Build a ratecard for a project from its confirmed (selected) matches.
|
||||
|
||||
For each selected match, looks up the hours per role from gmal_hours
|
||||
for the project's model type, multiplies by volume, and creates ratecard lines.
|
||||
|
||||
Returns the created RatecardLine objects.
|
||||
"""
|
||||
# Clear existing ratecard lines for this project
|
||||
existing = await db.execute(
|
||||
select(RatecardLine).where(RatecardLine.project_id == project.id)
|
||||
)
|
||||
for line in existing.scalars().all():
|
||||
await db.delete(line)
|
||||
await db.flush()
|
||||
|
||||
# Get all client assets with their selected matches
|
||||
assets_result = await db.execute(
|
||||
select(ClientAsset).where(ClientAsset.project_id == project.id)
|
||||
)
|
||||
client_assets = assets_result.scalars().all()
|
||||
|
||||
lines = []
|
||||
|
||||
for client_asset in client_assets:
|
||||
# Get the selected match for this asset
|
||||
match_result = await db.execute(
|
||||
select(Match).where(
|
||||
Match.client_asset_id == client_asset.id,
|
||||
Match.is_selected == True,
|
||||
)
|
||||
)
|
||||
selected_match = match_result.scalar_one_or_none()
|
||||
if not selected_match:
|
||||
logger.warning(f"No selected match for client asset {client_asset.id}: {client_asset.raw_name}")
|
||||
continue
|
||||
|
||||
# Get hours for this GMAL asset and model type
|
||||
hours_result = await db.execute(
|
||||
select(GmalHours).where(
|
||||
GmalHours.gmal_asset_id == selected_match.gmal_asset_id,
|
||||
GmalHours.model_type == project.model_type,
|
||||
)
|
||||
)
|
||||
gmal_hours = hours_result.scalars().all()
|
||||
|
||||
for gh in gmal_hours:
|
||||
total = round(float(gh.hours) * client_asset.volume, 2)
|
||||
line = RatecardLine(
|
||||
project_id=project.id,
|
||||
client_asset_id=client_asset.id,
|
||||
gmal_asset_id=selected_match.gmal_asset_id,
|
||||
role_id=gh.role_id,
|
||||
base_hours=float(gh.hours),
|
||||
volume=client_asset.volume,
|
||||
total_hours=total,
|
||||
)
|
||||
db.add(line)
|
||||
lines.append(line)
|
||||
|
||||
await db.flush()
|
||||
logger.info(f"Built ratecard with {len(lines)} lines for project {project.id}")
|
||||
return lines
|
||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
166
backend/app/utils/claude_client.py
Normal file
166
backend/app/utils/claude_client.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"""Anthropic Claude API client wrapper with token tracking and debug log."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
import anthropic
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MODEL = "claude-opus-4-6"
|
||||
|
||||
# Cost per million tokens (USD)
|
||||
INPUT_COST_PER_M = 3.0
|
||||
OUTPUT_COST_PER_M = 15.0
|
||||
|
||||
# Thread-safe token tracking + debug log
|
||||
_lock = threading.Lock()
|
||||
_usage = {
|
||||
"total_input_tokens": 0,
|
||||
"total_output_tokens": 0,
|
||||
"total_cost_usd": 0.0,
|
||||
"call_count": 0,
|
||||
}
|
||||
_debug_log: list[dict] = [] # Last N AI interactions
|
||||
MAX_DEBUG_LOG = 50
|
||||
|
||||
|
||||
def get_client() -> anthropic.Anthropic:
|
||||
return anthropic.Anthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
|
||||
def get_usage_stats() -> dict:
|
||||
with _lock:
|
||||
return {**_usage}
|
||||
|
||||
|
||||
def get_debug_log() -> list[dict]:
|
||||
with _lock:
|
||||
return list(_debug_log)
|
||||
|
||||
|
||||
def reset_usage_stats():
|
||||
with _lock:
|
||||
_usage["total_input_tokens"] = 0
|
||||
_usage["total_output_tokens"] = 0
|
||||
_usage["total_cost_usd"] = 0.0
|
||||
_usage["call_count"] = 0
|
||||
_debug_log.clear()
|
||||
|
||||
|
||||
def call_claude(
|
||||
system: str,
|
||||
user_message: str,
|
||||
tools: list[dict] | None = None,
|
||||
tool_choice: dict | None = None,
|
||||
max_tokens: int = 4096,
|
||||
) -> dict:
|
||||
"""Make a Claude API call, optionally with tool_use for structured output."""
|
||||
client = get_client()
|
||||
|
||||
kwargs = {
|
||||
"model": MODEL,
|
||||
"max_tokens": max_tokens,
|
||||
"system": system,
|
||||
"messages": [{"role": "user", "content": user_message}],
|
||||
}
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
kwargs["tool_choice"] = tool_choice or {"type": "auto"}
|
||||
|
||||
# Build debug entry
|
||||
entry = {
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"model": MODEL,
|
||||
"system_prompt": system[:500] + ("..." if len(system) > 500 else ""),
|
||||
"user_message_length": len(user_message),
|
||||
"user_message_preview": user_message[:1000] + ("..." if len(user_message) > 1000 else ""),
|
||||
"tools": [t["name"] for t in tools] if tools else [],
|
||||
"tool_choice": tool_choice,
|
||||
"status": "pending",
|
||||
}
|
||||
|
||||
try:
|
||||
response = client.messages.create(**kwargs)
|
||||
|
||||
# Parse response content
|
||||
response_parts = []
|
||||
tool_results = []
|
||||
for block in response.content:
|
||||
if block.type == "text":
|
||||
response_parts.append({"type": "text", "text": block.text[:1000]})
|
||||
elif block.type == "tool_use":
|
||||
tool_data = block.input
|
||||
tool_results.append({"tool": block.name, "input": tool_data})
|
||||
response_parts.append({
|
||||
"type": "tool_use",
|
||||
"tool": block.name,
|
||||
"input_preview": json.dumps(tool_data, default=str)[:2000],
|
||||
})
|
||||
|
||||
entry["status"] = "success"
|
||||
entry["stop_reason"] = response.stop_reason
|
||||
entry["response_parts"] = response_parts
|
||||
entry["tool_results_count"] = len(tool_results)
|
||||
|
||||
# Track token usage
|
||||
inp = 0
|
||||
out = 0
|
||||
cost = 0.0
|
||||
if hasattr(response, "usage") and response.usage:
|
||||
inp = response.usage.input_tokens or 0
|
||||
out = response.usage.output_tokens or 0
|
||||
cost = (inp / 1_000_000) * INPUT_COST_PER_M + (out / 1_000_000) * OUTPUT_COST_PER_M
|
||||
|
||||
entry["input_tokens"] = inp
|
||||
entry["output_tokens"] = out
|
||||
entry["cost_usd"] = round(cost, 6)
|
||||
|
||||
with _lock:
|
||||
_usage["total_input_tokens"] += inp
|
||||
_usage["total_output_tokens"] += out
|
||||
_usage["total_cost_usd"] += cost
|
||||
_usage["call_count"] += 1
|
||||
|
||||
# Attach usage to response for callers to save per-project
|
||||
response._usage_info = {"input_tokens": inp, "output_tokens": out, "cost_usd": cost}
|
||||
|
||||
logger.info(
|
||||
f"Claude API call: {inp} in / {out} out tokens, "
|
||||
f"${cost:.4f} this call, ${_usage['total_cost_usd']:.4f} total"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
entry["status"] = "error"
|
||||
entry["error"] = str(e)
|
||||
logger.error(f"Claude API error: {e}")
|
||||
raise
|
||||
|
||||
finally:
|
||||
with _lock:
|
||||
_debug_log.append(entry)
|
||||
if len(_debug_log) > MAX_DEBUG_LOG:
|
||||
_debug_log.pop(0)
|
||||
|
||||
|
||||
def extract_tool_result(response) -> dict | None:
|
||||
"""Extract the first tool_use result from a Claude response."""
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
return block.input
|
||||
return None
|
||||
|
||||
|
||||
def extract_text(response) -> str:
|
||||
"""Extract text content from a Claude response."""
|
||||
parts = []
|
||||
for block in response.content:
|
||||
if block.type == "text":
|
||||
parts.append(block.text)
|
||||
return "\n".join(parts)
|
||||
14
backend/requirements.txt
Normal file
14
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
fastapi[standard]==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
alembic==1.14.1
|
||||
asyncpg==0.30.0
|
||||
psycopg2-binary==2.9.10
|
||||
openpyxl==3.1.5
|
||||
python-docx==1.1.2
|
||||
python-multipart==0.0.19
|
||||
anthropic==0.43.0
|
||||
reportlab==4.2.5
|
||||
pandas==2.2.3
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.1
|
||||
20
backend/start.sh
Executable file
20
backend/start.sh
Executable file
|
|
@ -0,0 +1,20 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
export PYTHONPATH=/app
|
||||
|
||||
echo "Initializing database..."
|
||||
python -c "
|
||||
from sqlalchemy import create_engine
|
||||
from app.database import Base
|
||||
from app.models import *
|
||||
import os
|
||||
|
||||
engine = create_engine(os.environ['DATABASE_URL_SYNC'])
|
||||
Base.metadata.create_all(bind=engine)
|
||||
engine.dispose()
|
||||
print('Database tables created successfully')
|
||||
"
|
||||
|
||||
echo "Starting FastAPI server..."
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
47
docker-compose.yml
Normal file
47
docker-compose.yml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: scope_builder
|
||||
POSTGRES_USER: scope_user
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scope_pass_2024}
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U scope_user -d scope_builder"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://scope_user:${POSTGRES_PASSWORD:-scope_pass_2024}@db:5432/scope_builder
|
||||
DATABASE_URL_SYNC: postgresql://scope_user:${POSTGRES_PASSWORD:-scope_pass_2024}@db:5432/scope_builder
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
|
||||
ports:
|
||||
- "8001:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./data:/app/data
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "3010:3000"
|
||||
volumes:
|
||||
- ./frontend/src:/app/src
|
||||
- ./frontend/public:/app/public
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
11
frontend/Dockerfile
Normal file
11
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Scope Builder</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "scope-builder-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"axios": "^1.7.9",
|
||||
"lucide-react": "^0.468.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.16",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
307
frontend/src/App.css
Normal file
307
frontend/src/App.css
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
.app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.nav {
|
||||
background: #13151c;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.nav-inner {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 0 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
color: #fff !important;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.03em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
background: var(--color-primary);
|
||||
color: #000;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--color-text-secondary) !important;
|
||||
text-decoration: none;
|
||||
padding: 7px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--color-text) !important;
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.nav-link-active {
|
||||
color: #fff !important;
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.nav-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ai-tracker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: rgba(255, 196, 7, 0.08);
|
||||
border: 1px solid rgba(255, 196, 7, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
.ai-tracker-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ai-tracker-cost {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.ai-tracker-detail {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.main {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
/* Debug Panel */
|
||||
.debug-panel {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: #0a0b0f;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.debug-toggle {
|
||||
position: absolute;
|
||||
top: -32px;
|
||||
right: 28px;
|
||||
background: #0a0b0f;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-bottom: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
padding: 6px 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.debug-toggle:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.debug-open {
|
||||
height: 45vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.debug-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 12px 20px;
|
||||
font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.debug-empty {
|
||||
color: var(--color-text-muted);
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.debug-entry {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.debug-entry.debug-error {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.debug-entry-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
background: rgba(255,255,255,0.02);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.debug-entry-header:hover {
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
.debug-status {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.debug-status-success {
|
||||
background: var(--color-success-bg);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.debug-status-error {
|
||||
background: var(--color-danger-bg);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.debug-status-pending {
|
||||
background: var(--color-warning-bg);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.debug-time {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.debug-tools {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.debug-tokens {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.debug-reason {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.debug-expand {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.debug-entry-body {
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.debug-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.debug-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.debug-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.debug-pre {
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.debug-response-part {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.debug-part-type {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.debug-tool-name {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.debug-error-text {
|
||||
color: var(--color-danger);
|
||||
border-color: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
225
frontend/src/App.tsx
Normal file
225
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from './api/client';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import NewProject from './pages/NewProject';
|
||||
import ProjectView from './pages/ProjectView';
|
||||
import GmalBrowser from './pages/GmalBrowser';
|
||||
import Help from './pages/Help';
|
||||
import GmalEditor from './pages/GmalEditor';
|
||||
import './App.css';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: 'Projects' },
|
||||
{ path: '/gmal', label: 'GMAL Browser' },
|
||||
{ path: '/gmal-editor', label: 'GMAL Editor' },
|
||||
{ path: '/help', label: 'Help' },
|
||||
];
|
||||
|
||||
interface AiUsage {
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_cost_usd: number;
|
||||
call_count: number;
|
||||
}
|
||||
|
||||
interface DebugEntry {
|
||||
timestamp: string;
|
||||
model: string;
|
||||
system_prompt: string;
|
||||
user_message_length: number;
|
||||
user_message_preview: string;
|
||||
tools: string[];
|
||||
tool_choice: any;
|
||||
status: string;
|
||||
stop_reason?: string;
|
||||
response_parts?: Array<{ type: string; text?: string; tool?: string; input_preview?: string }>;
|
||||
tool_results_count?: number;
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
cost_usd?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function AiCostTracker() {
|
||||
const [usage, setUsage] = useState<AiUsage | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsage();
|
||||
const interval = setInterval(loadUsage, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
async function loadUsage() {
|
||||
try {
|
||||
const res = await api.get('/ai/usage');
|
||||
setUsage(res.data);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!usage) return null;
|
||||
|
||||
return (
|
||||
<div className="ai-tracker">
|
||||
<div className="ai-tracker-label">AI Cost</div>
|
||||
<div className="ai-tracker-cost">${usage.total_cost_usd.toFixed(4)}</div>
|
||||
<div className="ai-tracker-detail">
|
||||
{usage.call_count} calls · {(usage.total_input_tokens / 1000).toFixed(1)}k in · {(usage.total_output_tokens / 1000).toFixed(1)}k out
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DebugPanel() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [entries, setEntries] = useState<DebugEntry[]>([]);
|
||||
const [expanded, setExpanded] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadDebug();
|
||||
const interval = setInterval(loadDebug, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function loadDebug() {
|
||||
try {
|
||||
const res = await api.get('/ai/debug');
|
||||
setEntries(res.data);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`debug-panel ${open ? 'debug-open' : ''}`}>
|
||||
<button className="debug-toggle" onClick={() => setOpen(!open)}>
|
||||
{open ? 'Hide' : 'Show'} AI Debug ({entries.length})
|
||||
</button>
|
||||
{open && (
|
||||
<div className="debug-content">
|
||||
{entries.length === 0 ? (
|
||||
<div className="debug-empty">No AI calls yet. Upload a document or run matching.</div>
|
||||
) : (
|
||||
[...entries].reverse().map((e, idx) => {
|
||||
const realIdx = entries.length - 1 - idx;
|
||||
const isExpanded = expanded === realIdx;
|
||||
return (
|
||||
<div key={realIdx} className={`debug-entry debug-${e.status}`}>
|
||||
<div className="debug-entry-header" onClick={() => setExpanded(isExpanded ? null : realIdx)}>
|
||||
<span className={`debug-status debug-status-${e.status}`}>
|
||||
{e.status === 'success' ? 'OK' : 'ERR'}
|
||||
</span>
|
||||
<span className="debug-time">{new Date(e.timestamp + 'Z').toLocaleTimeString()}</span>
|
||||
<span className="debug-tools">{e.tools.join(', ') || 'no tools'}</span>
|
||||
{e.input_tokens != null && (
|
||||
<span className="debug-tokens">
|
||||
{e.input_tokens} in / {e.output_tokens} out · ${e.cost_usd?.toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
<span className="debug-reason">{e.stop_reason || ''}</span>
|
||||
<span className="debug-expand">{isExpanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="debug-entry-body">
|
||||
<div className="debug-section">
|
||||
<div className="debug-label">System Prompt</div>
|
||||
<pre className="debug-pre">{e.system_prompt}</pre>
|
||||
</div>
|
||||
|
||||
<div className="debug-section">
|
||||
<div className="debug-label">User Message ({e.user_message_length.toLocaleString()} chars)</div>
|
||||
<pre className="debug-pre">{e.user_message_preview}</pre>
|
||||
</div>
|
||||
|
||||
{e.tool_choice && (
|
||||
<div className="debug-section">
|
||||
<div className="debug-label">Tool Choice</div>
|
||||
<pre className="debug-pre">{JSON.stringify(e.tool_choice)}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{e.response_parts && e.response_parts.length > 0 && (
|
||||
<div className="debug-section">
|
||||
<div className="debug-label">Response ({e.response_parts.length} parts)</div>
|
||||
{e.response_parts.map((part, pi) => (
|
||||
<div key={pi} className="debug-response-part">
|
||||
<span className="debug-part-type">{part.type}</span>
|
||||
{part.type === 'text' && <pre className="debug-pre">{part.text}</pre>}
|
||||
{part.type === 'tool_use' && (
|
||||
<div>
|
||||
<div className="debug-tool-name">Tool: {part.tool}</div>
|
||||
<pre className="debug-pre">{part.input_preview}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{e.error && (
|
||||
<div className="debug-section">
|
||||
<div className="debug-label">Error</div>
|
||||
<pre className="debug-pre debug-error-text">{e.error}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavBar() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<nav className="nav">
|
||||
<div className="nav-inner">
|
||||
<Link to="/" className="logo">
|
||||
<span className="logo-icon">S</span>
|
||||
Scope Builder
|
||||
</Link>
|
||||
<div className="nav-links">
|
||||
{navItems.map(item => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`nav-link ${location.pathname === item.path ? 'nav-link-active' : ''}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="nav-spacer" />
|
||||
<AiCostTracker />
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="app">
|
||||
<NavBar />
|
||||
<main className="main">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/new" element={<NewProject />} />
|
||||
<Route path="/projects/:id/*" element={<ProjectView />} />
|
||||
<Route path="/gmal" element={<GmalBrowser />} />
|
||||
<Route path="/gmal-editor" element={<GmalEditor />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<DebugPanel />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
7
frontend/src/api/client.ts
Normal file
7
frontend/src/api/client.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
});
|
||||
|
||||
export default api;
|
||||
90
frontend/src/index.css
Normal file
90
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
:root {
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--color-bg: #0f1117;
|
||||
--color-bg-card: #1a1d27;
|
||||
--color-bg-card-hover: #22252f;
|
||||
--color-bg-input: #1a1d27;
|
||||
--color-border: #2a2d3a;
|
||||
--color-border-light: #23262f;
|
||||
--color-text: #e8eaed;
|
||||
--color-text-secondary: #8b8fa3;
|
||||
--color-text-muted: #5f6378;
|
||||
--color-primary: #FFC407;
|
||||
--color-primary-hover: #FFD44F;
|
||||
--color-primary-bg: rgba(255, 196, 7, 0.12);
|
||||
--color-success: #22c55e;
|
||||
--color-success-bg: rgba(34, 197, 94, 0.12);
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-bg: rgba(245, 158, 11, 0.12);
|
||||
--color-danger: #ef4444;
|
||||
--color-danger-bg: rgba(239, 68, 68, 0.12);
|
||||
--color-info: #FFC407;
|
||||
--color-info-bg: rgba(255, 196, 7, 0.12);
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-primary);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
250
frontend/src/pages/Dashboard.css
Normal file
250
frontend/src/pages/Dashboard.css
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
.loading {
|
||||
text-align: center;
|
||||
padding: 80px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 9px 18px;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.15s;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-primary-hover);
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-bg-card-hover);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--color-success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
opacity: 0.9;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
opacity: 0.9;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.btn-yolo {
|
||||
background: linear-gradient(135deg, #f59e0b, #ef4444, #8b5cf6);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.btn-yolo:hover {
|
||||
opacity: 0.9;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
border: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-banner {
|
||||
background: var(--color-warning-bg);
|
||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 20px;
|
||||
margin-bottom: 24px;
|
||||
color: var(--color-warning);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
background: var(--color-primary-bg);
|
||||
color: var(--color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.project-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
border: 1px solid var(--color-border);
|
||||
text-decoration: none !important;
|
||||
color: inherit !important;
|
||||
transition: all 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
background: var(--color-bg-card-hover);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.badge-muted {
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background: var(--color-primary-bg);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--color-success-bg);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--color-warning-bg);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: var(--color-info-bg);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: var(--color-danger-bg);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.card-client {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
124
frontend/src/pages/Dashboard.tsx
Normal file
124
frontend/src/pages/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import api from '../api/client';
|
||||
import { Project, GmalStats } from '../types';
|
||||
import './Dashboard.css';
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
draft: 'badge-muted',
|
||||
parsing: 'badge-warning',
|
||||
matching: 'badge-info',
|
||||
review: 'badge-primary',
|
||||
building: 'badge-warning',
|
||||
finalized: 'badge-success',
|
||||
};
|
||||
|
||||
export default function Dashboard() {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [stats, setStats] = useState<GmalStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [ingesting, setIngesting] = useState(false);
|
||||
|
||||
useEffect(() => { loadData(); }, []);
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const [projRes, statsRes] = await Promise.all([
|
||||
api.get('/projects'),
|
||||
api.get('/gmal/stats'),
|
||||
]);
|
||||
setProjects(projRes.data);
|
||||
setStats(statsRes.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIngest() {
|
||||
if (!confirm('This will reload all GMAL data from the master file. Continue?')) return;
|
||||
setIngesting(true);
|
||||
try {
|
||||
const res = await api.post('/gmal/ingest');
|
||||
alert(`Ingestion complete: ${res.data.assets_loaded} assets, ${res.data.roles_loaded} roles, ${res.data.hours_loaded} hour records`);
|
||||
loadData();
|
||||
} catch (err: any) {
|
||||
alert(`Ingestion failed: ${err.response?.data?.detail || err.message}`);
|
||||
} finally {
|
||||
setIngesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="loading">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Projects</h1>
|
||||
<p className="page-subtitle">Scope new client ratecards against the GMAL master</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button onClick={handleIngest} disabled={ingesting} className="btn btn-secondary">
|
||||
{ingesting ? 'Ingesting...' : 'Ingest GMAL Data'}
|
||||
</button>
|
||||
<Link to="/new" className="btn btn-primary">+ New Project</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="stats-row">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total_assets}</div>
|
||||
<div className="stat-label">GMAL Assets</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total_roles}</div>
|
||||
<div className="stat-label">Roles</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total_hours_records.toLocaleString()}</div>
|
||||
<div className="stat-label">Hour Records</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{projects.length}</div>
|
||||
<div className="stat-label">Projects</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && stats.total_assets === 0 && (
|
||||
<div className="empty-banner">
|
||||
No GMAL data loaded yet. Click "Ingest GMAL Data" to load the master ratecard.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">S</div>
|
||||
<p>No projects yet</p>
|
||||
<Link to="/new" className="btn btn-primary">Create your first project</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="project-grid">
|
||||
{projects.map(p => (
|
||||
<Link key={p.id} to={`/projects/${p.id}`} className="project-card">
|
||||
<div className="card-header">
|
||||
<span className="card-name">{p.name}</span>
|
||||
<span className={`badge ${STATUS_COLORS[p.status] || 'badge-muted'}`}>
|
||||
{p.status}
|
||||
</span>
|
||||
</div>
|
||||
{p.client_name && <div className="card-client">{p.client_name}</div>}
|
||||
<div className="card-meta">
|
||||
<span>{p.asset_count} assets</span>
|
||||
<span>{new Date(p.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
220
frontend/src/pages/GmalBrowser.css
Normal file
220
frontend/src/pages/GmalBrowser.css
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
.gmal-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.gmal-list-panel {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gmal-detail-panel {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
overflow: auto;
|
||||
position: sticky;
|
||||
top: 72px;
|
||||
max-height: calc(100vh - 88px);
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
padding: 16px 18px 0;
|
||||
color: #fff;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.gmal-filters {
|
||||
padding: 12px 18px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-search {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
width: auto;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.gmal-asset-list {
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gmal-row {
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.gmal-row:hover {
|
||||
background: rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.gmal-row-active {
|
||||
background: var(--color-primary-bg) !important;
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.gmal-row-id {
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.gmal-row-name {
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.gmal-row-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.complexity-badge {
|
||||
background: rgba(255,255,255,0.06);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.hours-badge {
|
||||
background: var(--color-success-bg);
|
||||
color: var(--color-success);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.gmal-empty {
|
||||
padding: 24px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.gmal-detail-empty {
|
||||
padding: 60px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.gmal-detail {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.detail-gmal-id {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 2px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-field {}
|
||||
|
||||
.detail-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-text {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.detail-caveat {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-warning);
|
||||
background: var(--color-warning-bg);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.hours-model-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.hours-model-title {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.hours-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hours-table th {
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.hours-table td {
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.hours-table tr:hover td {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
205
frontend/src/pages/GmalBrowser.tsx
Normal file
205
frontend/src/pages/GmalBrowser.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import api from '../api/client';
|
||||
import { GmalAsset, GmalAssetWithHours, GmalHoursEntry } from '../types';
|
||||
import './GmalBrowser.css';
|
||||
|
||||
export default function GmalBrowser() {
|
||||
const [assets, setAssets] = useState<GmalAsset[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [subCategory, setSubCategory] = useState('');
|
||||
const [complexity, setComplexity] = useState('');
|
||||
const [subCategories, setSubCategories] = useState<string[]>([]);
|
||||
const [selected, setSelected] = useState<GmalAssetWithHours | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadAssets();
|
||||
loadSubCategories();
|
||||
}, []);
|
||||
|
||||
async function loadSubCategories() {
|
||||
try {
|
||||
const res = await api.get('/gmal/stats');
|
||||
setSubCategories(res.data.sub_categories);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadAssets() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { limit: 500 };
|
||||
if (search) params.search = search;
|
||||
if (subCategory) params.sub_category = subCategory;
|
||||
if (complexity) params.complexity_level = complexity;
|
||||
const res = await api.get('/gmal/assets', { params });
|
||||
setAssets(res.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectAsset(gmalId: string) {
|
||||
try {
|
||||
const res = await api.get(`/gmal/assets/${gmalId}`);
|
||||
setSelected(res.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
loadAssets();
|
||||
}
|
||||
|
||||
const hoursByModel: Record<string, GmalHoursEntry[]> = {};
|
||||
if (selected?.hours_by_role) {
|
||||
for (const h of selected.hours_by_role) {
|
||||
if (!hoursByModel[h.model_type]) hoursByModel[h.model_type] = [];
|
||||
hoursByModel[h.model_type].push(h);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gmal-layout">
|
||||
<div className="gmal-list-panel">
|
||||
<h2 className="panel-title">GMAL Assets</h2>
|
||||
|
||||
<div className="gmal-filters">
|
||||
<input
|
||||
className="input filter-search"
|
||||
placeholder="Search assets..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
<select className="input filter-select" value={subCategory} onChange={e => setSubCategory(e.target.value)}>
|
||||
<option value="">All Categories</option>
|
||||
{subCategories.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<select className="input filter-select" value={complexity} onChange={e => setComplexity(e.target.value)}>
|
||||
<option value="">All Complexity</option>
|
||||
<option value="1">Simple</option>
|
||||
<option value="2">Medium</option>
|
||||
<option value="3">Complex</option>
|
||||
</select>
|
||||
<button onClick={handleSearch} className="btn btn-primary">Search</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">Loading...</div>
|
||||
) : (
|
||||
<div className="gmal-asset-list">
|
||||
{assets.map(a => (
|
||||
<div
|
||||
key={a.gmal_id}
|
||||
onClick={() => selectAsset(a.gmal_id)}
|
||||
className={`gmal-row ${selected?.gmal_id === a.gmal_id ? 'gmal-row-active' : ''}`}
|
||||
>
|
||||
<div className="gmal-row-id">{a.gmal_id}</div>
|
||||
<div className="gmal-row-name">{a.unique_name || a.asset_name}</div>
|
||||
<div className="gmal-row-meta">
|
||||
<span>{a.sub_category}</span>
|
||||
<span className="complexity-badge">
|
||||
{a.complexity_name || `L${a.complexity_level}`}
|
||||
</span>
|
||||
{a.has_hour_routes && <span className="hours-badge">Has Hours</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{assets.length === 0 && (
|
||||
<div className="gmal-empty">
|
||||
No assets found. Try different criteria or ingest GMAL data first.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="gmal-detail-panel">
|
||||
{!selected ? (
|
||||
<div className="gmal-detail-empty">
|
||||
Select an asset to view details
|
||||
</div>
|
||||
) : (
|
||||
<div className="gmal-detail">
|
||||
<h2 className="detail-gmal-id">{selected.gmal_id}</h2>
|
||||
<h3 className="detail-name">{selected.unique_name || selected.asset_name}</h3>
|
||||
|
||||
<div className="detail-grid">
|
||||
<div className="detail-field">
|
||||
<div className="detail-label">Category</div>
|
||||
<div className="detail-value">{selected.category || '-'}</div>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<div className="detail-label">Sub Category</div>
|
||||
<div className="detail-value">{selected.sub_category || '-'}</div>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<div className="detail-label">Complexity</div>
|
||||
<div className="detail-value">{selected.complexity_name} (Level {selected.complexity_level})</div>
|
||||
</div>
|
||||
<div className="detail-field">
|
||||
<div className="detail-label">Master/Adapt</div>
|
||||
<div className="detail-value">{selected.master_adapt || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected.asset_description && (
|
||||
<div className="detail-section">
|
||||
<div className="detail-label">Asset Description</div>
|
||||
<div className="detail-text">{selected.asset_description}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.complexity_description && (
|
||||
<div className="detail-section">
|
||||
<div className="detail-label">Complexity Description</div>
|
||||
<div className="detail-text">{selected.complexity_description}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.caveats && (
|
||||
<div className="detail-section">
|
||||
<div className="detail-label">Caveats</div>
|
||||
<div className="detail-caveat">{selected.caveats}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(hoursByModel).length > 0 && (
|
||||
<div className="detail-section">
|
||||
<div className="detail-label">Hours by Role</div>
|
||||
{Object.entries(hoursByModel).map(([model, hours]) => (
|
||||
<div key={model} className="hours-model-group">
|
||||
<div className="hours-model-title">
|
||||
{model.replace(/_/g, ' ').toUpperCase()}
|
||||
</div>
|
||||
<table className="hours-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Discipline</th>
|
||||
<th>Role</th>
|
||||
<th className="text-right">Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hours.filter(h => h.hours > 0).map((h, i) => (
|
||||
<tr key={i}>
|
||||
<td className="td-discipline">{h.discipline}</td>
|
||||
<td>{h.role_title}</td>
|
||||
<td className="text-right td-total">{h.hours.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
313
frontend/src/pages/GmalEditor.css
Normal file
313
frontend/src/pages/GmalEditor.css
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
.editor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
gap: 18px;
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.editor-list {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-list-header {
|
||||
padding: 14px 14px 10px;
|
||||
}
|
||||
|
||||
.editor-list-header .panel-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.editor-search {
|
||||
font-size: 12px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.editor-asset-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.editor-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
font-size: 12px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.editor-row:hover {
|
||||
background: rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.editor-row-active {
|
||||
background: var(--color-primary-bg) !important;
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.editor-row-id {
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
width: 70px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.editor-row-name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.editor-row-complexity {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-main {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.editor-empty {
|
||||
padding: 60px;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-bg-card);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.editor-dirty {
|
||||
font-size: 11px;
|
||||
color: var(--color-warning);
|
||||
font-weight: 600;
|
||||
background: var(--color-warning-bg);
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.editor-toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.editor-fields {
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.editor-field-row {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.editor-field {
|
||||
flex: 1;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.editor-field label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.editor-field .input {
|
||||
font-size: 12px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.editor-textarea {
|
||||
min-height: 70px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.editor-textarea-sm {
|
||||
min-height: 48px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Hours section */
|
||||
.editor-hours-section {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.editor-hours-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.editor-hours-header h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.model-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.model-tab {
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.model-tab:hover {
|
||||
color: var(--color-text-secondary);
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
.model-tab-active {
|
||||
color: #fff;
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.hours-grid {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.hours-edit-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hours-edit-table th {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: rgba(255,255,255,0.02);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.th-discipline { width: 140px; }
|
||||
.th-role { }
|
||||
.th-hours { width: 100px; text-align: right; }
|
||||
|
||||
.hours-edit-table td {
|
||||
padding: 3px 12px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.td-disc {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
vertical-align: top;
|
||||
padding-top: 8px;
|
||||
border-right: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.td-role {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.td-hours-input {
|
||||
text-align: right;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.has-hours td {
|
||||
background: rgba(255, 196, 7, 0.03);
|
||||
}
|
||||
|
||||
.hours-input {
|
||||
width: 80px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
font-family: var(--font-sans);
|
||||
outline: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.hours-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-bg-input);
|
||||
color: var(--color-text);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-bg);
|
||||
}
|
||||
|
||||
.hours-input.hours-active {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hours-input.hours-dirty {
|
||||
border-color: var(--color-warning);
|
||||
background: var(--color-warning-bg);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.hours-input::placeholder {
|
||||
color: var(--color-border);
|
||||
}
|
||||
|
||||
/* Hide number spinners */
|
||||
.hours-input::-webkit-outer-spin-button,
|
||||
.hours-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.hours-input[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
313
frontend/src/pages/GmalEditor.tsx
Normal file
313
frontend/src/pages/GmalEditor.tsx
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
import { useEffect, useState, useCallback } from 'react';
|
||||
import api from '../api/client';
|
||||
import { GmalAsset, GmalAssetWithHours, GmalHoursEntry, Role, MODEL_TYPE_LABELS } from '../types';
|
||||
import './GmalEditor.css';
|
||||
|
||||
interface EditableAsset {
|
||||
asset_name: string;
|
||||
sub_category: string;
|
||||
category: string;
|
||||
complexity_level: number | null;
|
||||
complexity_name: string;
|
||||
unique_name: string;
|
||||
asset_description: string;
|
||||
complexity_description: string;
|
||||
caveats: string;
|
||||
master_adapt: string;
|
||||
ai_efficiency_pct: number | null;
|
||||
}
|
||||
|
||||
interface HourCell {
|
||||
role_id: number;
|
||||
model_type: string;
|
||||
hours: number;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
const MODEL_TYPES = ['current_oplus', 'ai_oplus', 'current_local', 'ai_local', 'asset_factory'];
|
||||
|
||||
export default function GmalEditor() {
|
||||
const [assets, setAssets] = useState<GmalAsset[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<GmalAssetWithHours | null>(null);
|
||||
const [editFields, setEditFields] = useState<EditableAsset | null>(null);
|
||||
const [hourCells, setHourCells] = useState<HourCell[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState('current_oplus');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([loadAssets(), loadRoles()]).then(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function loadAssets() {
|
||||
try {
|
||||
const res = await api.get('/gmal/assets', { params: { limit: 500 } });
|
||||
setAssets(res.data);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadRoles() {
|
||||
try {
|
||||
const res = await api.get('/gmal/roles');
|
||||
setRoles(res.data);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function selectAsset(gmalId: string) {
|
||||
if (dirty && !confirm('You have unsaved changes. Discard?')) return;
|
||||
try {
|
||||
const res = await api.get(`/gmal/assets/${gmalId}`);
|
||||
const asset = res.data as GmalAssetWithHours;
|
||||
setSelected(asset);
|
||||
setEditFields({
|
||||
asset_name: asset.asset_name || '',
|
||||
sub_category: asset.sub_category || '',
|
||||
category: asset.category || '',
|
||||
complexity_level: asset.complexity_level,
|
||||
complexity_name: asset.complexity_name || '',
|
||||
unique_name: asset.unique_name || '',
|
||||
asset_description: asset.asset_description || '',
|
||||
complexity_description: asset.complexity_description || '',
|
||||
caveats: asset.caveats || '',
|
||||
master_adapt: asset.master_adapt || '',
|
||||
ai_efficiency_pct: asset.ai_efficiency_pct,
|
||||
});
|
||||
// Build hour cells from existing data
|
||||
const cells: HourCell[] = [];
|
||||
for (const h of asset.hours_by_role) {
|
||||
cells.push({ role_id: h.role_id, model_type: h.model_type, hours: h.hours, dirty: false });
|
||||
}
|
||||
setHourCells(cells);
|
||||
setDirty(false);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function handleFieldChange(field: keyof EditableAsset, value: string | number | null) {
|
||||
if (!editFields) return;
|
||||
setEditFields({ ...editFields, [field]: value });
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
function getHour(roleId: number, modelType: string): number {
|
||||
const cell = hourCells.find(c => c.role_id === roleId && c.model_type === modelType);
|
||||
return cell ? cell.hours : 0;
|
||||
}
|
||||
|
||||
function setHour(roleId: number, modelType: string, value: number) {
|
||||
setHourCells(prev => {
|
||||
const idx = prev.findIndex(c => c.role_id === roleId && c.model_type === modelType);
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...updated[idx], hours: value, dirty: true };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, { role_id: roleId, model_type: modelType, hours: value, dirty: true }];
|
||||
});
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!selected || !editFields) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
// Save asset fields
|
||||
await api.put(`/gmal/assets/${selected.gmal_id}`, editFields);
|
||||
|
||||
// Save dirty hours
|
||||
const dirtyHours = hourCells.filter(c => c.dirty);
|
||||
if (dirtyHours.length > 0) {
|
||||
await api.put(`/gmal/assets/${selected.gmal_id}/hours`, dirtyHours.map(c => ({
|
||||
role_id: c.role_id,
|
||||
model_type: c.model_type,
|
||||
hours: c.hours,
|
||||
})));
|
||||
}
|
||||
|
||||
setDirty(false);
|
||||
// Reload to get fresh data
|
||||
await selectAsset(selected.gmal_id);
|
||||
await loadAssets();
|
||||
} catch (err: any) {
|
||||
alert(`Save failed: ${err.response?.data?.detail || err.message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
loadAssets();
|
||||
}
|
||||
|
||||
// Group roles by discipline for the hours table
|
||||
const disciplines = [...new Set(roles.map(r => r.discipline))];
|
||||
|
||||
const filteredAssets = search
|
||||
? assets.filter(a =>
|
||||
(a.gmal_id + ' ' + (a.asset_name || '') + ' ' + (a.unique_name || '')).toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: assets;
|
||||
|
||||
if (loading) return <div className="loading">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="editor-layout">
|
||||
<div className="editor-list">
|
||||
<div className="editor-list-header">
|
||||
<h2 className="panel-title">GMAL Assets</h2>
|
||||
<input
|
||||
className="input editor-search"
|
||||
placeholder="Filter assets..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-asset-list">
|
||||
{filteredAssets.map(a => (
|
||||
<div
|
||||
key={a.gmal_id}
|
||||
onClick={() => selectAsset(a.gmal_id)}
|
||||
className={`editor-row ${selected?.gmal_id === a.gmal_id ? 'editor-row-active' : ''}`}
|
||||
>
|
||||
<span className="editor-row-id">{a.gmal_id}</span>
|
||||
<span className="editor-row-name">{a.asset_name}</span>
|
||||
<span className="editor-row-complexity">{a.complexity_name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-main">
|
||||
{!selected || !editFields ? (
|
||||
<div className="editor-empty">Select a GMAL asset to edit</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="editor-toolbar">
|
||||
<h2 className="editor-title">{selected.gmal_id}</h2>
|
||||
{dirty && <span className="editor-dirty">Unsaved changes</span>}
|
||||
<div className="editor-toolbar-spacer" />
|
||||
<button onClick={handleSave} disabled={saving || !dirty} className="btn btn-primary">
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="editor-fields">
|
||||
<div className="editor-field-row">
|
||||
<div className="editor-field">
|
||||
<label>Asset Name</label>
|
||||
<input className="input" value={editFields.asset_name} onChange={e => handleFieldChange('asset_name', e.target.value)} />
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label>Unique Name</label>
|
||||
<input className="input" value={editFields.unique_name} onChange={e => handleFieldChange('unique_name', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-field-row">
|
||||
<div className="editor-field">
|
||||
<label>Category</label>
|
||||
<input className="input" value={editFields.category} onChange={e => handleFieldChange('category', e.target.value)} />
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label>Sub Category</label>
|
||||
<input className="input" value={editFields.sub_category} onChange={e => handleFieldChange('sub_category', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-field-row">
|
||||
<div className="editor-field" style={{ flex: '0 0 120px' }}>
|
||||
<label>Complexity</label>
|
||||
<select className="input" value={editFields.complexity_level ?? ''} onChange={e => handleFieldChange('complexity_level', e.target.value ? parseInt(e.target.value) : null)}>
|
||||
<option value="">-</option>
|
||||
<option value="1">1 - Simple</option>
|
||||
<option value="2">2 - Medium</option>
|
||||
<option value="3">3 - Complex</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="editor-field" style={{ flex: '0 0 120px' }}>
|
||||
<label>Master/Adapt</label>
|
||||
<input className="input" value={editFields.master_adapt} onChange={e => handleFieldChange('master_adapt', e.target.value)} />
|
||||
</div>
|
||||
<div className="editor-field" style={{ flex: '0 0 140px' }}>
|
||||
<label>AI Efficiency %</label>
|
||||
<input className="input" type="number" step="0.01" value={editFields.ai_efficiency_pct ?? ''} onChange={e => handleFieldChange('ai_efficiency_pct', e.target.value ? parseFloat(e.target.value) : null)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label>Asset Description</label>
|
||||
<textarea className="input editor-textarea" value={editFields.asset_description} onChange={e => handleFieldChange('asset_description', e.target.value)} />
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label>Complexity Description</label>
|
||||
<textarea className="input editor-textarea-sm" value={editFields.complexity_description} onChange={e => handleFieldChange('complexity_description', e.target.value)} />
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label>Caveats</label>
|
||||
<textarea className="input editor-textarea-sm" value={editFields.caveats} onChange={e => handleFieldChange('caveats', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-hours-section">
|
||||
<div className="editor-hours-header">
|
||||
<h3>Hours by Role</h3>
|
||||
<div className="model-tabs">
|
||||
{MODEL_TYPES.map(mt => (
|
||||
<button
|
||||
key={mt}
|
||||
onClick={() => setSelectedModel(mt)}
|
||||
className={`model-tab ${selectedModel === mt ? 'model-tab-active' : ''}`}
|
||||
>
|
||||
{MODEL_TYPE_LABELS[mt]?.replace('Model - ', '').replace(' Market', '') || mt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hours-grid">
|
||||
<table className="hours-edit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="th-discipline">Discipline</th>
|
||||
<th className="th-role">Role</th>
|
||||
<th className="th-hours">Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{disciplines.map(disc => {
|
||||
const discRoles = roles.filter(r => r.discipline === disc);
|
||||
const hasAnyHours = discRoles.some(r => getHour(r.id, selectedModel) > 0);
|
||||
return discRoles.map((role, ri) => {
|
||||
const hrs = getHour(role.id, selectedModel);
|
||||
const cell = hourCells.find(c => c.role_id === role.id && c.model_type === selectedModel);
|
||||
const isDirty = cell?.dirty;
|
||||
return (
|
||||
<tr key={role.id} className={hrs > 0 ? 'has-hours' : ''}>
|
||||
{ri === 0 && (
|
||||
<td className="td-disc" rowSpan={discRoles.length}>{disc}</td>
|
||||
)}
|
||||
<td className="td-role">{role.role_title}</td>
|
||||
<td className="td-hours-input">
|
||||
<input
|
||||
type="number"
|
||||
step="0.25"
|
||||
min="0"
|
||||
className={`hours-input ${isDirty ? 'hours-dirty' : ''} ${hrs > 0 ? 'hours-active' : ''}`}
|
||||
value={hrs || ''}
|
||||
placeholder="0"
|
||||
onChange={e => setHour(role.id, selectedModel, parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
frontend/src/pages/Help.css
Normal file
177
frontend/src/pages/Help.css
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
.help-page {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.help-page .page-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 28px;
|
||||
letter-spacing: -0.03em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.help-heading {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.help-intro {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.help-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.help-step {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-primary);
|
||||
color: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.help-step h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.help-step p {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.help-step ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.help-step li {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 4px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.help-note {
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.conf-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conf-tag-exact { background: var(--color-success); }
|
||||
.conf-tag-close { background: var(--color-warning); color: #000; }
|
||||
.conf-tag-multiple { background: var(--color-primary); color: #000; }
|
||||
.conf-tag-none { background: var(--color-danger); }
|
||||
|
||||
.model-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.model-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.model-card h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.model-card p {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.model-tag {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.caveat-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.caveat-list li {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
padding: 6px 0;
|
||||
padding-left: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.caveat-list li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 13px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.help-section > p {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
125
frontend/src/pages/Help.tsx
Normal file
125
frontend/src/pages/Help.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import './Help.css';
|
||||
|
||||
export default function Help() {
|
||||
return (
|
||||
<div className="help-page">
|
||||
<h1 className="page-title">Help & Guide</h1>
|
||||
|
||||
<section className="help-section">
|
||||
<h2 className="help-heading">How Scope Builder Works</h2>
|
||||
<div className="help-steps">
|
||||
<div className="help-step">
|
||||
<div className="step-num">1</div>
|
||||
<div>
|
||||
<h3>Create a Project</h3>
|
||||
<p>Start by creating a new project with a name, client, and selecting a <strong>model type</strong> (see below). The model type determines which set of hourly rates are used for the ratecard.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="help-step">
|
||||
<div className="step-num">2</div>
|
||||
<div>
|
||||
<h3>Upload a Client Brief</h3>
|
||||
<p>Upload the client's scope document (.docx or .xlsx). The AI will read through it and extract every distinct deliverable/asset, along with descriptions, complexity hints, and volumes.</p>
|
||||
<p className="help-note">Client documents can be in any format or naming convention - the AI handles the interpretation.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="help-step">
|
||||
<div className="step-num">3</div>
|
||||
<div>
|
||||
<h3>AI Matching</h3>
|
||||
<p>Click "Run AI Matching" to match each client asset to the closest GMAL equivalent(s). For each match you'll see:</p>
|
||||
<ul>
|
||||
<li><span className="conf-tag conf-tag-exact">EXACT</span> Direct match to a GMAL asset</li>
|
||||
<li><span className="conf-tag conf-tag-close">CLOSE</span> Similar but with notable differences</li>
|
||||
<li><span className="conf-tag conf-tag-multiple">MULTIPLE</span> Several candidates - you choose</li>
|
||||
<li><span className="conf-tag conf-tag-none">NONE</span> No reasonable match found</li>
|
||||
</ul>
|
||||
<p>Each match includes reasoning and caveats explaining what the GMAL does and doesn't cover vs what the client asked for.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="help-step">
|
||||
<div className="step-num">4</div>
|
||||
<div>
|
||||
<h3>Review & Select</h3>
|
||||
<p>Review each match, read the caveats, and select the best GMAL for each client asset. You can also manually assign a GMAL if the AI got it wrong.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="help-step">
|
||||
<div className="step-num">5</div>
|
||||
<div>
|
||||
<h3>Build Ratecard</h3>
|
||||
<p>Once all assets have a selected match, click "Build Ratecard". This looks up the hours per role for each matched GMAL and multiplies by the asset volume to produce the full ratecard.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="help-step">
|
||||
<div className="step-num">6</div>
|
||||
<div>
|
||||
<h3>Export</h3>
|
||||
<p><strong>Excel</strong> - Full ratecard with roles x assets matrix and asset detail sheet.</p>
|
||||
<p><strong>PDF</strong> - Caveats report explaining each match, confidence level, and differences for stakeholder review.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="help-section">
|
||||
<h2 className="help-heading">Model Types</h2>
|
||||
<p className="help-intro">Each model type represents a different delivery structure with different hourly allocations per role. Choose the one that matches the client's engagement model.</p>
|
||||
|
||||
<div className="model-cards">
|
||||
<div className="model-card">
|
||||
<h3>Current Model - O+ Market</h3>
|
||||
<p>The standard delivery model for O+ (Omnichannel Plus) markets. Uses the current established team structure and hour allocations. This is the <strong>default</strong> and most commonly used model.</p>
|
||||
<div className="model-tag">Best for: Standard client scopes in established O+ markets</div>
|
||||
</div>
|
||||
|
||||
<div className="model-card">
|
||||
<h3>AI Model - O+ Market</h3>
|
||||
<p>An AI-augmented version of the O+ model with adjusted hour allocations that factor in AI-assisted production. Typically results in fewer hours per asset for certain roles where AI tools increase efficiency.</p>
|
||||
<div className="model-tag">Best for: Forward-looking scopes that account for AI productivity gains</div>
|
||||
</div>
|
||||
|
||||
<div className="model-card">
|
||||
<h3>Current Model - Local Market</h3>
|
||||
<p>The standard delivery model for local/single-market engagements. Hour allocations may differ from O+ to reflect the simpler coordination needs of local-only delivery.</p>
|
||||
<div className="model-tag">Best for: Single-market or local-only client engagements</div>
|
||||
</div>
|
||||
|
||||
<div className="model-card">
|
||||
<h3>AI Model - Local Market</h3>
|
||||
<p>AI-augmented version of the local market model. Combines the local delivery structure with AI efficiency adjustments.</p>
|
||||
<div className="model-tag">Best for: Local engagements with AI productivity assumptions</div>
|
||||
</div>
|
||||
|
||||
<div className="model-card">
|
||||
<h3>Asset Factory Model</h3>
|
||||
<p>A high-volume, production-line approach optimised for asset adaptation and localisation at scale. Typically uses different role mixes and lower hours per asset.</p>
|
||||
<div className="model-tag">Best for: High-volume adaptation and localisation work</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="help-section">
|
||||
<h2 className="help-heading">Understanding Caveats</h2>
|
||||
<p>When the AI matches a client asset to a GMAL, it generates caveats to flag important differences. Common caveat types:</p>
|
||||
<ul className="caveat-list">
|
||||
<li><strong>Scope gaps</strong> - The GMAL doesn't fully cover what the client described (e.g. client wants animation but the matched GMAL is static only)</li>
|
||||
<li><strong>Complexity mismatch</strong> - The closest GMAL is a different complexity level than what the client implied</li>
|
||||
<li><strong>Format differences</strong> - Different output formats, sizes, or channels than specified</li>
|
||||
<li><strong>Additional work</strong> - The client scope includes elements that would require separate GMAL assets</li>
|
||||
<li><strong>No match</strong> - Nothing in the GMAL catalog closely resembles the client's requirement - this needs manual scoping</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="help-section">
|
||||
<h2 className="help-heading">AI Cost Tracking</h2>
|
||||
<p>The gold cost indicator in the top-right of the navigation bar shows real-time spending on Claude API calls for the current session. Pricing:</p>
|
||||
<ul className="caveat-list">
|
||||
<li><strong>Input tokens</strong> - $3.00 per million tokens (the document text and GMAL catalog sent to Claude)</li>
|
||||
<li><strong>Output tokens</strong> - $15.00 per million tokens (Claude's match results and reasoning)</li>
|
||||
</ul>
|
||||
<p>Cost resets when the backend restarts. Typical costs: ~$0.01-0.05 per document parse, ~$0.02-0.10 per asset matched.</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
frontend/src/pages/NewProject.css
Normal file
83
frontend/src/pages/NewProject.css
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
.new-project {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.new-project .page-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 24px;
|
||||
letter-spacing: -0.03em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 28px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--color-border);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--color-bg-input);
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-bg);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
select.input {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%238b8fa3' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
select.input option {
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 28px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
87
frontend/src/pages/NewProject.tsx
Normal file
87
frontend/src/pages/NewProject.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api/client';
|
||||
import { MODEL_TYPE_LABELS } from '../types';
|
||||
import './NewProject.css';
|
||||
|
||||
export default function NewProject() {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState('');
|
||||
const [clientName, setClientName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [modelType, setModelType] = useState('current_oplus');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await api.post('/projects', {
|
||||
name: name.trim(),
|
||||
client_name: clientName.trim() || null,
|
||||
description: description.trim() || null,
|
||||
model_type: modelType,
|
||||
});
|
||||
navigate(`/projects/${res.data.id}`);
|
||||
} catch (err: any) {
|
||||
alert(`Failed to create project: ${err.response?.data?.detail || err.message}`);
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="new-project">
|
||||
<h1 className="page-title">New Project</h1>
|
||||
<form onSubmit={handleSubmit} className="form-card">
|
||||
<div className="field">
|
||||
<label className="label">Project Name <span className="required">*</span></label>
|
||||
<input
|
||||
className="input"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. Acme Corp Q2 2025 Scope"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="label">Client Name</label>
|
||||
<input
|
||||
className="input"
|
||||
value={clientName}
|
||||
onChange={e => setClientName(e.target.value)}
|
||||
placeholder="e.g. Acme Corp"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="label">Description</label>
|
||||
<textarea
|
||||
className="input textarea"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this scope..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="label">Model Type</label>
|
||||
<select className="input" value={modelType} onChange={e => setModelType(e.target.value)}>
|
||||
{Object.entries(MODEL_TYPE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" onClick={() => navigate('/')} className="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" disabled={creating || !name.trim()} className="btn btn-primary">
|
||||
{creating ? 'Creating...' : 'Create Project'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
468
frontend/src/pages/ProjectView.css
Normal file
468
frontend/src/pages/ProjectView.css
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
.project-view {}
|
||||
|
||||
.pv-header {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-size: 12px;
|
||||
color: var(--color-primary) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pv-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
margin: 4px 0 8px;
|
||||
letter-spacing: -0.03em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pv-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pv-client {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.pv-model {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pv-cost {
|
||||
color: var(--color-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10px 18px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.tab-active {
|
||||
color: #fff;
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tab-content {}
|
||||
|
||||
/* Upload */
|
||||
.upload-zone {
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 48px 32px;
|
||||
text-align: center;
|
||||
background: var(--color-bg-card);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.upload-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: #fff;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.upload-desc {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-file {
|
||||
margin-top: 14px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assets-section {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 14px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.asset-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.asset-item {
|
||||
background: var(--color-bg-card);
|
||||
padding: 14px 18px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.asset-name {
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.asset-desc {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.asset-vol {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* Matches */
|
||||
.match-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.match-group {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
margin-bottom: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.match-group-header {
|
||||
padding: 12px 18px;
|
||||
background: rgba(255,255,255,0.02);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.match-asset-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.match-asset-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.match-asset-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.match-selected-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.conf-badge-sm {
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
span.conf-badge-sm.conf-exact { background: var(--color-success); }
|
||||
span.conf-badge-sm.conf-close { background: var(--color-warning); color: #000; }
|
||||
span.conf-badge-sm.conf-multiple { background: var(--color-primary); color: #000; }
|
||||
span.conf-badge-sm.conf-none { background: var(--color-danger); }
|
||||
|
||||
.match-expand-hint {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.match-group-collapsed-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.match-group-expanded .match-group-collapsed-body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.match-group-collapsed .match-group-header {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.match-asset-desc {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.match-asset-vol {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.match-empty {
|
||||
padding: 20px 18px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.match-card {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.match-card:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.match-selected {
|
||||
background: rgba(34, 197, 94, 0.04);
|
||||
}
|
||||
|
||||
.match-card-accent {
|
||||
width: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conf-exact { background: var(--color-success); }
|
||||
.conf-close { background: var(--color-warning); }
|
||||
.conf-multiple { background: var(--color-info); }
|
||||
.conf-none { background: var(--color-danger); }
|
||||
|
||||
.match-card-body {
|
||||
flex: 1;
|
||||
padding: 12px 18px;
|
||||
}
|
||||
|
||||
.match-card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.match-gmal-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.match-gmal-id {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.match-gmal-name {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.match-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conf-badge {
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
span.conf-exact { background: var(--color-success); }
|
||||
span.conf-close { background: var(--color-warning); color: #000; }
|
||||
span.conf-multiple { background: var(--color-info); color: #000; }
|
||||
span.conf-none { background: var(--color-danger); }
|
||||
|
||||
.btn-select {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-select:hover {
|
||||
background: var(--color-primary-bg);
|
||||
}
|
||||
|
||||
.selected-label {
|
||||
color: var(--color-success);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.match-reasoning {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.match-caveat {
|
||||
font-size: 12px;
|
||||
color: var(--color-warning);
|
||||
margin-top: 6px;
|
||||
background: var(--color-warning-bg);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Ratecard */
|
||||
.rc-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.rc-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.rc-total {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.rc-assets {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rc-exports {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.rc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rc-table th {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.rc-table td {
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.rc-table tr:hover td {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
.td-discipline {
|
||||
color: var(--color-text-muted) !important;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.td-gmal {
|
||||
color: var(--color-primary) !important;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.td-total {
|
||||
color: #fff !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-right { text-align: right; }
|
||||
.text-center { text-align: center; }
|
||||
409
frontend/src/pages/ProjectView.tsx
Normal file
409
frontend/src/pages/ProjectView.tsx
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import api from '../api/client';
|
||||
import { Project, ClientAsset, Match, RatecardSummary, MODEL_TYPE_LABELS, CONFIDENCE_COLORS } from '../types';
|
||||
import './ProjectView.css';
|
||||
|
||||
type Tab = 'upload' | 'matches' | 'ratecard';
|
||||
|
||||
const CONF_CLASS: Record<string, string> = {
|
||||
exact: 'conf-exact',
|
||||
close: 'conf-close',
|
||||
multiple: 'conf-multiple',
|
||||
none: 'conf-none',
|
||||
};
|
||||
|
||||
export default function ProjectView() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [tab, setTab] = useState<Tab>('upload');
|
||||
const [assets, setAssets] = useState<ClientAsset[]>([]);
|
||||
const [matches, setMatches] = useState<Match[]>([]);
|
||||
const [ratecard, setRatecard] = useState<RatecardSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [matching, setMatching] = useState(false);
|
||||
const [building, setBuilding] = useState(false);
|
||||
|
||||
const loadProject = useCallback(async () => {
|
||||
try {
|
||||
const [projRes, assetsRes] = await Promise.all([
|
||||
api.get(`/projects/${id}`),
|
||||
api.get(`/projects/${id}/client-assets`),
|
||||
]);
|
||||
setProject(projRes.data);
|
||||
setAssets(assetsRes.data);
|
||||
|
||||
if (assetsRes.data.length > 0) {
|
||||
const matchRes = await api.get(`/projects/${id}/matches`);
|
||||
setMatches(matchRes.data);
|
||||
}
|
||||
|
||||
if (['finalized', 'building'].includes(projRes.data.status)) {
|
||||
try {
|
||||
const rcRes = await api.get(`/projects/${id}/ratecard`);
|
||||
setRatecard(rcRes.data);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (projRes.data.status === 'finalized') setTab('ratecard');
|
||||
else if (assetsRes.data.length > 0) setTab('matches');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { loadProject(); }, [loadProject]);
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
await api.post(`/projects/${id}/upload`, form);
|
||||
await loadProject();
|
||||
setTab('matches');
|
||||
} catch (err: any) {
|
||||
alert(`Upload failed: ${err.response?.data?.detail || err.message}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMatch() {
|
||||
setMatching(true);
|
||||
// Start polling for matches while the request runs
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const matchRes = await api.get(`/projects/${id}/matches`);
|
||||
setMatches(matchRes.data);
|
||||
} catch {}
|
||||
}, 3000);
|
||||
|
||||
try {
|
||||
await api.post(`/projects/${id}/match`);
|
||||
await loadProject();
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('cancel')) {
|
||||
alert(`Matching failed: ${err.response?.data?.detail || err.message}`);
|
||||
}
|
||||
await loadProject();
|
||||
} finally {
|
||||
clearInterval(pollInterval);
|
||||
setMatching(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelMatch() {
|
||||
try {
|
||||
await api.post(`/projects/${id}/match/cancel`);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleYolo() {
|
||||
// For each asset without a selected match, select the rank 1 match
|
||||
const unselected = assets.filter(a => {
|
||||
const am = matchesByAsset[a.id];
|
||||
return am && am.length > 0 && !am.some(m => m.is_selected);
|
||||
});
|
||||
|
||||
for (const a of unselected) {
|
||||
const topMatch = matchesByAsset[a.id]?.find(m => m.rank === 1) || matchesByAsset[a.id]?.[0];
|
||||
if (topMatch) {
|
||||
try {
|
||||
await api.put(`/projects/${id}/matches/${topMatch.id}/select`, { is_selected: true });
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
await loadProject();
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`Delete project "${project?.name}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await api.delete(`/projects/${id}`);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
alert(`Failed to delete: ${err.response?.data?.detail || err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectMatch(matchId: number) {
|
||||
try {
|
||||
await api.put(`/projects/${id}/matches/${matchId}/select`, { is_selected: true });
|
||||
await loadProject();
|
||||
} catch (err: any) {
|
||||
alert(`Failed: ${err.response?.data?.detail || err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBuildRatecard() {
|
||||
setBuilding(true);
|
||||
try {
|
||||
await api.post(`/projects/${id}/ratecard/build`);
|
||||
await loadProject();
|
||||
setTab('ratecard');
|
||||
} catch (err: any) {
|
||||
alert(`Build failed: ${err.response?.data?.detail || err.message}`);
|
||||
} finally {
|
||||
setBuilding(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="loading">Loading...</div>;
|
||||
if (!project) return <div className="loading">Project not found</div>;
|
||||
|
||||
const matchesByAsset: Record<number, Match[]> = {};
|
||||
for (const m of matches) {
|
||||
if (!matchesByAsset[m.client_asset_id]) matchesByAsset[m.client_asset_id] = [];
|
||||
matchesByAsset[m.client_asset_id].push(m);
|
||||
}
|
||||
|
||||
const allAssetsHaveSelectedMatch = assets.length > 0 && assets.every(
|
||||
a => matchesByAsset[a.id]?.some(m => m.is_selected)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="project-view">
|
||||
<div className="pv-header">
|
||||
<div>
|
||||
<Link to="/" className="back-link">Back to Projects</Link>
|
||||
<h1 className="pv-title">{project.name}</h1>
|
||||
<div className="pv-meta">
|
||||
{project.client_name && <span className="pv-client">{project.client_name}</span>}
|
||||
<span className={`badge ${project.status === 'finalized' ? 'badge-success' : 'badge-muted'}`}>
|
||||
{project.status}
|
||||
</span>
|
||||
<span className="pv-model">{MODEL_TYPE_LABELS[project.model_type]}</span>
|
||||
{(project as any).ai_cost_usd > 0 && (
|
||||
<span className="pv-cost">
|
||||
AI: ${(project as any).ai_cost_usd.toFixed(4)} ({(project as any).ai_call_count} calls)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleDelete} className="btn btn-danger btn-sm">Delete Project</button>
|
||||
</div>
|
||||
|
||||
<div className="tabs">
|
||||
{(['upload', 'matches', 'ratecard'] as Tab[]).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`tab ${tab === t ? 'tab-active' : ''}`}
|
||||
>
|
||||
{t === 'upload' ? `Upload & Assets (${assets.length})` :
|
||||
t === 'matches' ? `Match Review (${matches.length})` :
|
||||
'Ratecard'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'upload' && (
|
||||
<div className="tab-content">
|
||||
<div className="upload-zone">
|
||||
<div className="upload-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="upload-title">Upload Client Document</p>
|
||||
<p className="upload-desc">Word (.docx) or Excel (.xlsx) file with the client's asset brief</p>
|
||||
<label className="btn btn-primary upload-btn">
|
||||
{uploading ? 'Uploading & Parsing...' : 'Choose File'}
|
||||
<input type="file" accept=".docx,.xlsx,.txt" onChange={handleUpload} hidden disabled={uploading} />
|
||||
</label>
|
||||
{project.source_filename && (
|
||||
<p className="upload-file">Current: {project.source_filename}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{assets.length > 0 && (
|
||||
<div className="assets-section">
|
||||
<h3 className="section-title">Extracted Assets ({assets.length})</h3>
|
||||
<div className="asset-list">
|
||||
{assets.map(a => (
|
||||
<div key={a.id} className="asset-item">
|
||||
<div className="asset-name">{a.raw_name}</div>
|
||||
{a.raw_description && <div className="asset-desc">{a.raw_description}</div>}
|
||||
<div className="asset-vol">Volume: {a.volume}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'matches' && (
|
||||
<div className="tab-content">
|
||||
<div className="match-actions">
|
||||
<button onClick={handleMatch} disabled={matching || assets.length === 0} className="btn btn-primary">
|
||||
{matching ? `Matching... (${matches.length} found)` : 'Run AI Matching'}
|
||||
</button>
|
||||
{matching && (
|
||||
<button onClick={handleCancelMatch} className="btn btn-danger">
|
||||
Stop Matching
|
||||
</button>
|
||||
)}
|
||||
{!allAssetsHaveSelectedMatch && !matching && matches.length > 0 && (
|
||||
<button onClick={handleYolo} className="btn btn-yolo">
|
||||
YOLO - Select All Top Matches
|
||||
</button>
|
||||
)}
|
||||
{allAssetsHaveSelectedMatch && !matching && (
|
||||
<button onClick={handleBuildRatecard} disabled={building} className="btn btn-success">
|
||||
{building ? 'Building...' : 'Build Ratecard'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{assets.map(a => {
|
||||
const assetMatches = matchesByAsset[a.id] || [];
|
||||
const selectedMatch = assetMatches.find(m => m.is_selected);
|
||||
const isCollapsed = !!selectedMatch && (selectedMatch.confidence_score ?? 0) >= 0.8;
|
||||
return (
|
||||
<div key={a.id} className={`match-group ${isCollapsed ? 'match-group-collapsed' : ''}`}>
|
||||
<div
|
||||
className="match-group-header"
|
||||
onClick={() => {
|
||||
// Toggle expand/collapse by toggling a CSS class
|
||||
const el = document.getElementById(`match-group-${a.id}`);
|
||||
el?.classList.toggle('match-group-expanded');
|
||||
}}
|
||||
style={{ cursor: isCollapsed ? 'pointer' : 'default' }}
|
||||
>
|
||||
<div className="match-asset-info">
|
||||
<div className="match-asset-name-row">
|
||||
<span className="match-asset-name">{a.raw_name}</span>
|
||||
{selectedMatch && (
|
||||
<span className="match-selected-summary">
|
||||
<span className={`conf-badge-sm ${CONF_CLASS[selectedMatch.confidence]}`}>
|
||||
{Math.round((selectedMatch.confidence_score || 0) * 100)}%
|
||||
</span>
|
||||
{selectedMatch.gmal_id} - {selectedMatch.gmal_unique_name || selectedMatch.gmal_name}
|
||||
</span>
|
||||
)}
|
||||
{isCollapsed && <span className="match-expand-hint">click to expand</span>}
|
||||
</div>
|
||||
{a.raw_description && (
|
||||
<span className="match-asset-desc">{a.raw_description}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="match-asset-vol">Vol: {a.volume}</span>
|
||||
</div>
|
||||
|
||||
<div id={`match-group-${a.id}`} className={`match-group-body ${isCollapsed ? 'match-group-collapsed-body' : ''}`}>
|
||||
{assetMatches.length === 0 ? (
|
||||
<div className="match-empty">
|
||||
No matches yet. Click "Run AI Matching" to find GMAL equivalents.
|
||||
</div>
|
||||
) : (
|
||||
assetMatches.map(m => (
|
||||
<div key={m.id} className={`match-card ${m.is_selected ? 'match-selected' : ''}`}>
|
||||
<div className={`match-card-accent ${CONF_CLASS[m.confidence]}`} />
|
||||
<div className="match-card-body">
|
||||
<div className="match-card-top">
|
||||
<div className="match-gmal-info">
|
||||
<span className="match-gmal-id">{m.gmal_id}</span>
|
||||
<span className="match-gmal-name">{m.gmal_unique_name || m.gmal_name}</span>
|
||||
</div>
|
||||
<div className="match-card-actions">
|
||||
<span className={`conf-badge ${CONF_CLASS[m.confidence]}`}>
|
||||
{m.confidence} {m.confidence_score ? `${Math.round(m.confidence_score * 100)}%` : ''}
|
||||
</span>
|
||||
{!m.is_selected ? (
|
||||
<button onClick={(e) => { e.stopPropagation(); handleSelectMatch(m.id); }} className="btn-select">
|
||||
Select
|
||||
</button>
|
||||
) : (
|
||||
<span className="selected-label">Selected</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{m.ai_reasoning && (
|
||||
<div className="match-reasoning">
|
||||
<strong>Reasoning:</strong> {m.ai_reasoning}
|
||||
</div>
|
||||
)}
|
||||
{m.caveat_text && (
|
||||
<div className="match-caveat">
|
||||
<strong>Caveats:</strong> {m.caveat_text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'ratecard' && (
|
||||
<div className="tab-content">
|
||||
{!ratecard || ratecard.lines.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: 40 }}>
|
||||
No ratecard built yet. Complete matching and click "Build Ratecard".
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rc-header">
|
||||
<div className="rc-stats">
|
||||
<span className="rc-total">Total Hours: {ratecard.total_hours.toLocaleString()}</span>
|
||||
<span className="rc-assets">{ratecard.total_assets} assets</span>
|
||||
</div>
|
||||
<div className="rc-exports">
|
||||
<a href={`/api/projects/${id}/ratecard/export/excel`} className="btn btn-secondary">
|
||||
Export Excel
|
||||
</a>
|
||||
<a href={`/api/projects/${id}/ratecard/export/pdf`} className="btn btn-secondary">
|
||||
Export PDF Caveats
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="rc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Discipline</th>
|
||||
<th>Role</th>
|
||||
<th>Asset</th>
|
||||
<th>GMAL</th>
|
||||
<th className="text-right">Base Hrs</th>
|
||||
<th className="text-center">Vol</th>
|
||||
<th className="text-right">Total Hrs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ratecard.lines.map(l => (
|
||||
<tr key={l.id}>
|
||||
<td className="td-discipline">{l.discipline}</td>
|
||||
<td>{l.role_title}</td>
|
||||
<td>{l.client_asset_name}</td>
|
||||
<td className="td-gmal">{l.gmal_id}</td>
|
||||
<td className="text-right">{l.base_hours?.toFixed(2)}</td>
|
||||
<td className="text-center">{l.volume}</td>
|
||||
<td className="text-right td-total">
|
||||
{(l.manual_override ?? l.total_hours)?.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
frontend/src/types/index.ts
Normal file
125
frontend/src/types/index.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
export interface GmalAsset {
|
||||
id: number;
|
||||
gmal_id: string;
|
||||
asset_name: string | null;
|
||||
sub_category: string | null;
|
||||
complexity_level: number | null;
|
||||
complexity_name: string | null;
|
||||
unique_name: string | null;
|
||||
has_hour_routes: boolean;
|
||||
category?: string | null;
|
||||
asset_description?: string | null;
|
||||
complexity_description?: string | null;
|
||||
caveats?: string | null;
|
||||
ai_enhanced_description?: string | null;
|
||||
master_adapt?: string | null;
|
||||
ai_efficiency_pct?: number | null;
|
||||
}
|
||||
|
||||
export interface GmalHoursEntry {
|
||||
role_id: number;
|
||||
role_title: string;
|
||||
discipline: string;
|
||||
model_type: string;
|
||||
hours: number;
|
||||
}
|
||||
|
||||
export interface GmalAssetWithHours extends GmalAsset {
|
||||
hours_by_role: GmalHoursEntry[];
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: number;
|
||||
discipline: string;
|
||||
role_title: string;
|
||||
entity: string | null;
|
||||
resource_location: string | null;
|
||||
unique_name: string | null;
|
||||
sort_order: number | null;
|
||||
is_programme_role: boolean;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: number;
|
||||
name: string;
|
||||
client_name: string | null;
|
||||
description: string | null;
|
||||
model_type: string;
|
||||
status: string;
|
||||
source_filename: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
asset_count: number;
|
||||
}
|
||||
|
||||
export interface ClientAsset {
|
||||
id: number;
|
||||
project_id: number;
|
||||
raw_name: string | null;
|
||||
raw_description: string | null;
|
||||
volume: number;
|
||||
sort_order: number | null;
|
||||
}
|
||||
|
||||
export interface Match {
|
||||
id: number;
|
||||
client_asset_id: number;
|
||||
gmal_asset_id: number;
|
||||
gmal_id: string | null;
|
||||
gmal_name: string | null;
|
||||
gmal_unique_name: string | null;
|
||||
confidence: 'exact' | 'close' | 'multiple' | 'none';
|
||||
confidence_score: number | null;
|
||||
ai_reasoning: string | null;
|
||||
caveat_text: string | null;
|
||||
is_selected: boolean;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
export interface RatecardLine {
|
||||
id: number;
|
||||
client_asset_id: number;
|
||||
client_asset_name: string | null;
|
||||
gmal_asset_id: number;
|
||||
gmal_id: string | null;
|
||||
role_id: number;
|
||||
role_title: string | null;
|
||||
discipline: string | null;
|
||||
base_hours: number | null;
|
||||
volume: number;
|
||||
total_hours: number | null;
|
||||
manual_override: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface RatecardSummary {
|
||||
project_id: number;
|
||||
project_name: string;
|
||||
model_type: string;
|
||||
total_assets: number;
|
||||
total_hours: number;
|
||||
lines: RatecardLine[];
|
||||
}
|
||||
|
||||
export interface GmalStats {
|
||||
total_assets: number;
|
||||
total_roles: number;
|
||||
total_hours_records: number;
|
||||
categories: string[];
|
||||
sub_categories: string[];
|
||||
}
|
||||
|
||||
export const MODEL_TYPE_LABELS: Record<string, string> = {
|
||||
current_oplus: 'Current Model - O+ Market',
|
||||
ai_oplus: 'AI Model - O+ Market',
|
||||
current_local: 'Current Model - Local Market',
|
||||
ai_local: 'AI Model - Local Market',
|
||||
asset_factory: 'Asset Factory Model',
|
||||
};
|
||||
|
||||
export const CONFIDENCE_COLORS: Record<string, string> = {
|
||||
exact: '#22c55e',
|
||||
close: '#f59e0b',
|
||||
multiple: '#FFC407',
|
||||
none: '#ef4444',
|
||||
};
|
||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
16
frontend/vite.config.ts
Normal file
16
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue