Primer commit

This commit is contained in:
Luis Sanchez 2025-12-22 22:52:38 -05:00
commit e2b9b85a40
112 changed files with 20010 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
**/.DS_Store
datasumaq/**
backend/venv/**
backend/alembic/.DS_Store
diseño.md
backend/uploads/**

11
backend/.env Normal file
View File

@ -0,0 +1,11 @@
# Google Gemini API Key
GOOGLE_API_KEY="****"
# Base de Datos (PostgreSQL)
DATABASE_URL=postgresql://***:***@localhost:5432/postgres
DB_USER=***
DB_PASSWORD=***
DB_HOST=localhost
DB_PORT=5432
DB_NAME=postgres

146
backend/alembic.ini Normal file
View File

@ -0,0 +1,146 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = postgresql://postgres:postgresql@localhost:5432/postgres
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
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

1
backend/alembic/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

88
backend/alembic/env.py Normal file
View File

@ -0,0 +1,88 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from database import Base
from models import *
import os
from dotenv import load_dotenv
load_dotenv()
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
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() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
section = config.get_section(config.config_ini_section, {})
section["sqlalchemy.url"] = os.getenv("DATABASE_URL", section.get("sqlalchemy.url"))
connectable = engine_from_config(
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()

View File

@ -0,0 +1,28 @@
"""${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 identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[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:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,34 @@
"""crear tablas
Revision ID: e59d56a35fe9
Revises:
Create Date: 2025-12-22 19:05:11.969285
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e59d56a35fe9'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('projects', sa.Column('parent_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'projects', 'projects', ['parent_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'projects', type_='foreignkey')
op.drop_column('projects', 'parent_id')
# ### end Alembic commands ###

24
backend/database.py Normal file
View File

@ -0,0 +1,24 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
from dotenv import load_dotenv
load_dotenv()
# Using PostgreSQL for production
SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgresql@localhost:5432/postgres")
engine = create_engine(
SQLALCHEMY_DATABASE_URL
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

70
backend/init_db.py Normal file
View File

@ -0,0 +1,70 @@
from database import engine, Base, SessionLocal
from models import User, Project, Specialty, Contractor, Activity, NonConformity, Evidence, UserRole
from security import get_password_hash # Import hashing function
import datetime
def init_db():
print("Creating tables...")
Base.metadata.create_all(bind=engine)
print("Tables created.")
def seed_data():
db = SessionLocal()
# Check if data exists
if db.query(User).first():
print("Data already exists.")
db.close()
return
print("Seeding data...")
# Users - Password is 'secret' for everyone
hashed = get_password_hash("secret")
admin = User(email="admin@fritosfresh.com", hashed_password=hashed, full_name="Admin User", role=UserRole.ADMIN)
supervisor = User(email="super@fritosfresh.com", hashed_password=hashed, full_name="Juan Perez", role=UserRole.SUPERVISOR)
db.add(admin)
db.add(supervisor)
db.commit()
# Specialties
specs = ["Civil", "Mecánica", "Eléctrica", "SSOMA"]
for s_name in specs:
db.add(Specialty(name=s_name))
db.commit()
# Project
proj = Project(name="Planta ATE - Expansion", code="ATE-EXP-2025", location="Ate, Lima", start_date=datetime.datetime.now())
db.add(proj)
db.commit()
# Contractor
cont = Contractor(name="Constructora Global", ruc="20123456789")
db.add(cont)
db.commit()
# Activity
civil_spec = db.query(Specialty).filter_by(name="Civil").first()
act = Activity(
project_id=proj.id,
specialty_id=civil_spec.id,
contractor_id=cont.id,
user_id=supervisor.id,
description="Inspeccion de cimientos",
area="Zona Norte"
)
db.add(act)
db.commit()
# NC
nc = NonConformity(activity_id=act.id, description="Grieta en muro", level="major")
db.add(nc)
db.commit()
print("Seeding complete.")
db.close()
if __name__ == "__main__":
init_db()
seed_data()

49
backend/main.py Normal file
View File

@ -0,0 +1,49 @@
import sys
# Compatibility patch for importlib.metadata in Python < 3.10
# This fixes the AttributeError: module 'importlib.metadata' has no attribute 'packages_distributions'
if sys.version_info < (3, 10):
try:
import importlib_metadata
import importlib
importlib.metadata = importlib_metadata
except ImportError:
pass
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from routers import auth, users, projects, activities, specialties, contractors, transcription, non_conformities
import os
from fastapi.staticfiles import StaticFiles
app = FastAPI(title="Sistema de Supervision API", version="0.1.0")
# CORS (allow all for dev)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(projects.router)
app.include_router(activities.router)
app.include_router(specialties.router)
app.include_router(contractors.router)
app.include_router(transcription.router)
app.include_router(non_conformities.router)
# Mount uploads directory to serve files
if not os.path.exists("uploads"):
os.makedirs("uploads")
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
@app.get("/")
def read_root():
return {"message": "Sistema de Supervision API is running"}
@app.get("/health")
def health_check():
return {"status": "ok"}

48
backend/migrate.py Normal file
View File

@ -0,0 +1,48 @@
import os
from dotenv import load_dotenv
from sqlalchemy import create_engine, text
from alembic import command
from alembic.config import Config
# Cargamos variables de entorno
load_dotenv()
# Construimos la URL de conexión
DATABASE_URL = os.getenv("DATABASE_URL")
if not DATABASE_URL:
DATABASE_URL = (
f"postgresql://{os.getenv('DB_USER')}:{os.getenv('DB_PASSWORD')}"
f"@{os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}/{os.getenv('DB_NAME')}"
)
# Configuración de Alembic
alembic_cfg = Config("alembic.ini")
# Limpiar alembic_version huérfana si existe
engine = create_engine(DATABASE_URL)
with engine.connect() as conn:
result = conn.execute(
text(
"SELECT EXISTS ("
"SELECT 1 FROM information_schema.tables "
"WHERE table_name='alembic_version'"
")"
)
)
exists = result.scalar()
if exists:
print("⚠️ Tabla alembic_version existente detectada. Eliminando...")
conn.execute(text("DROP TABLE alembic_version"))
conn.commit()
# Crear migración automáticamente
print("🛠️ Generando migración...")
command.revision(alembic_cfg, message="crear tablas", autogenerate=True)
# Aplicar migración
print("⬆️ Aplicando migración (upgrade head)...")
command.upgrade(alembic_cfg, "head")
print("✅ Migración completada correctamente")

167
backend/models.py Normal file
View File

@ -0,0 +1,167 @@
from sqlalchemy import Table, Column, Integer, String, Boolean, ForeignKey, DateTime, Text, Enum, JSON
from sqlalchemy.orm import relationship
from database import Base
import datetime
import enum
# Junction Tables
project_specialties = Table(
'project_specialties',
Base.metadata,
Column('project_id', Integer, ForeignKey('projects.id'), primary_key=True),
Column('specialty_id', Integer, ForeignKey('specialties.id'), primary_key=True)
)
project_contractors = Table(
'project_contractors',
Base.metadata,
Column('project_id', Integer, ForeignKey('projects.id'), primary_key=True),
Column('contractor_id', Integer, ForeignKey('contractors.id'), primary_key=True)
)
class UserRole(str, enum.Enum):
ADMIN = "admin"
DIRECTOR = "director"
SUPERVISOR = "supervisor"
COORDINATOR = "coordinator"
CONTRACTOR = "contractor"
CLIENT = "client"
class ActivityType(str, enum.Enum):
INSPECTION = "inspection"
MEETING = "meeting"
VIRTUAL_MEETING = "virtual_meeting"
COORDINATION = "coordination"
TEST = "test"
OTHER = "other"
class NCLevel(str, enum.Enum):
CRITICAL = "critical"
MAJOR = "major"
MINOR = "minor"
class NCType(str, enum.Enum):
HUMAN_ERROR = "Errores humanos"
PROCESS_FAILURE = "Fallas en los procesos"
DESIGN_ISSUE = "Problemas de diseño"
UNCONTROLLED_CHANGE = "Cambios no controlados"
COMMUNICATION_FAILURE = "Falta de comunicación"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
full_name = Column(String)
role = Column(Enum(UserRole), default=UserRole.SUPERVISOR)
is_active = Column(Boolean, default=True)
class Project(Base):
__tablename__ = "projects"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
code = Column(String, unique=True, index=True)
location = Column(String)
start_date = Column(DateTime)
end_date = Column(DateTime)
status = Column(String, default="active")
parent_id = Column(Integer, ForeignKey("projects.id"), nullable=True)
# Relationships
parent = relationship("Project", remote_side=[id], back_populates="subprojects")
subprojects = relationship("Project", back_populates="parent")
activities = relationship("Activity", back_populates="project")
specialties = relationship("Specialty", secondary=project_specialties)
contractors = relationship("Contractor", secondary=project_contractors)
class Specialty(Base):
__tablename__ = "specialties"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, nullable=False) # Civil, Mecánica, Eléctrica, SSOMA
class Contractor(Base):
__tablename__ = "contractors"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
ruc = Column(String, unique=True)
contact_name = Column(String)
email = Column(String, nullable=True)
phone = Column(String, nullable=True)
address = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
specialty_id = Column(Integer, ForeignKey("specialties.id"), nullable=True)
parent_id = Column(Integer, ForeignKey("contractors.id"), nullable=True)
specialty = relationship("Specialty")
parent = relationship("Contractor", remote_side=[id], back_populates="subcontractors")
subcontractors = relationship("Contractor", back_populates="parent")
class Activity(Base):
__tablename__ = "activities"
id = Column(Integer, primary_key=True, index=True)
project_id = Column(Integer, ForeignKey("projects.id"))
specialty_id = Column(Integer, ForeignKey("specialties.id"))
contractor_id = Column(Integer, ForeignKey("contractors.id"), nullable=True)
user_id = Column(Integer, ForeignKey("users.id")) # Reporter
date = Column(DateTime, default=datetime.datetime.utcnow)
end_date = Column(DateTime, nullable=True)
type = Column(Enum(ActivityType), default=ActivityType.INSPECTION)
area = Column(String) # Frente de obra / Linea
description = Column(Text)
observations = Column(Text)
audio_transcription = Column(Text, nullable=True)
status = Column(String, default="completed")
project = relationship("Project", back_populates="activities")
specialty = relationship("Specialty")
contractor = relationship("Contractor")
reporter = relationship("User")
non_conformities = relationship("NonConformity", back_populates="activity")
evidences = relationship("Evidence", back_populates="activity")
class NonConformity(Base):
__tablename__ = "non_conformities"
id = Column(Integer, primary_key=True, index=True)
activity_id = Column(Integer, ForeignKey("activities.id"))
level = Column(Enum(NCLevel), default=NCLevel.MINOR)
description = Column(Text, nullable=False)
status = Column(String, default="open") # open, closed
# New Fields
due_date = Column(DateTime, nullable=True)
responsible_person = Column(String, nullable=True)
action_checklist = Column(JSON, nullable=True) # List of dicts {text: str, completed: bool}
nc_type = Column(Enum(NCType), nullable=True)
impact_description = Column(Text, nullable=True)
closure_description = Column(Text, nullable=True)
activity = relationship("Activity", back_populates="non_conformities")
evidences = relationship("Evidence", back_populates="non_conformity")
class Evidence(Base):
__tablename__ = "evidences"
id = Column(Integer, primary_key=True, index=True)
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=True)
non_conformity_id = Column(Integer, ForeignKey("non_conformities.id"), nullable=True)
file_path = Column(String, nullable=False)
media_type = Column(String) # image, video, document
description = Column(String)
captured_at = Column(DateTime, default=datetime.datetime.utcnow)
# Transcription fields for audio
transcription = Column(Text, nullable=True)
transcription_status = Column(String, default="none") # none, pending, processing, completed, error
activity = relationship("Activity", back_populates="evidences")
non_conformity = relationship("NonConformity", back_populates="evidences")

82
backend/requirements.txt Normal file
View File

@ -0,0 +1,82 @@
alembic==1.16.5
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.12.0
bcrypt==3.2.2
cachetools==6.2.4
certifi==2025.11.12
cffi==2.0.0
charset-normalizer==3.4.4
click==8.1.8
cryptography==46.0.3
dnspython==2.7.0
ecdsa==0.19.1
email-validator==2.3.0
exceptiongroup==1.3.1
fastapi==0.125.0
fastapi-cli==0.0.16
fastapi-cloud-cli==0.7.0
fastar==0.8.0
google-ai-generativelanguage==0.6.15
google-api-core==2.28.1
google-api-python-client==2.187.0
google-auth==2.45.0
google-auth-httplib2==0.3.0
google-generativeai==0.8.6
googleapis-common-protos==1.72.0
grpcio==1.76.0
grpcio-status==1.71.2
h11==0.16.0
httpcore==1.0.9
httplib2==0.31.0
httptools==0.7.1
httpx==0.28.1
idna==3.11
iniconfig==2.1.0
Jinja2==3.1.6
Mako==1.3.10
markdown-it-py==3.0.0
MarkupSafe==3.0.3
mdurl==0.1.2
numpy==2.0.2
opencv-python-headless==4.12.0.88
packaging==25.0
passlib==1.7.4
pillow==11.3.0
pluggy==1.6.0
proto-plus==1.27.0
protobuf==5.29.5
psycopg2-binary==2.9.11
pyasn1==0.6.1
pyasn1_modules==0.4.2
pycparser==2.23
pydantic==2.12.5
pydantic_core==2.41.5
Pygments==2.19.2
pyparsing==3.2.5
pytest==8.4.2
python-dotenv==1.2.1
python-jose==3.5.0
python-multipart==0.0.20
PyYAML==6.0.3
requests==2.32.5
rich==14.2.0
rich-toolkit==0.17.1
rignore==0.7.6
rsa==4.9.1
sentry-sdk==2.48.0
shellingham==1.5.4
six==1.17.0
SQLAlchemy==2.0.45
starlette==0.49.3
tomli==2.3.0
tqdm==4.67.1
typer==0.20.0
typing-inspection==0.4.2
typing_extensions==4.15.0
uritemplate==4.2.0
urllib3==2.6.2
uvicorn==0.38.0
uvloop==0.22.1
watchfiles==1.1.1
websockets==15.0.1

View File

@ -0,0 +1,190 @@
import os
import shutil
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, BackgroundTasks
from sqlalchemy.orm import Session
from typing import List, Optional
from database import get_db
from models import Activity, Evidence, User
from security import get_current_active_user
import schemas
import uuid
from services import activities
router = APIRouter(
prefix="/activities",
tags=["Activities"]
)
UPLOAD_DIR = "uploads"
if not os.path.exists(UPLOAD_DIR):
os.makedirs(UPLOAD_DIR)
@router.post("/", response_model=schemas.Activity)
def create_activity(
activity: schemas.ActivityCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
return activities.create_activity(db, activity, current_user)
@router.get("/", response_model=List[schemas.Activity])
def read_activities(
project_id: Optional[int] = None,
specialty_id: Optional[int] = None,
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
db_activities = activities.get_activities(db, current_user, project_id, specialty_id, skip, limit)
return db_activities
@router.get("/{activity_id}", response_model=schemas.Activity)
def read_activity(
activity_id: int,
db: Session = Depends(get_db)
):
return activities.get_activity(db, activity_id)
@router.put("/{activity_id}", response_model=schemas.Activity)
def update_activity(
activity_id: int,
activity: schemas.ActivityUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
db_activity = db.query(Activity).filter(Activity.id == activity_id).first()
if not db_activity:
raise HTTPException(status_code=404, detail="Activity not found")
update_data = activity.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(db_activity, key, value)
db.commit()
db.refresh(db_activity)
return db_activity
@router.post("/{activity_id}/upload", response_model=schemas.Evidence)
async def upload_evidence(
activity_id: int,
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
description: Optional[str] = None,
captured_at: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
# Verify activity exists
db_activity = db.query(Activity).filter(Activity.id == activity_id).first()
if not db_activity:
raise HTTPException(status_code=404, detail="Activity not found")
# Generate unique filename
file_ext = os.path.splitext(file.filename)[1]
unique_filename = f"{uuid.uuid4()}{file_ext}"
file_path = os.path.join(UPLOAD_DIR, unique_filename)
# Save file
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
import datetime
db_captured_at = None
if captured_at:
try:
db_captured_at = datetime.datetime.fromisoformat(captured_at.replace('Z', '+00:00'))
except:
db_captured_at = datetime.datetime.utcnow()
# Determine transcription status
initial_status = "none"
if file.content_type and "audio" in file.content_type:
initial_status = "pending"
# Save to database
db_evidence = Evidence(
activity_id=activity_id,
file_path=file_path,
media_type=file.content_type,
description=description,
captured_at=db_captured_at,
transcription_status=initial_status
)
db.add(db_evidence)
db.commit()
db.refresh(db_evidence)
# If it's audio, queue transcription
if initial_status == "pending":
from services.transcription_worker import process_transcription
background_tasks.add_task(process_transcription, db_evidence.id)
return db_evidence
@router.post("/evidence/{evidence_id}/retry-transcription", response_model=schemas.Evidence)
async def retry_transcription(
evidence_id: int,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
db_evidence = db.query(Evidence).filter(Evidence.id == evidence_id).first()
if not db_evidence:
raise HTTPException(status_code=404, detail="Evidence not found")
if not db_evidence.media_type or "audio" not in db_evidence.media_type:
raise HTTPException(status_code=400, detail="Only audio evidence can be transcribed")
# Update status to pending
db_evidence.transcription_status = "pending"
db_evidence.transcription = None
db.commit()
db.refresh(db_evidence)
# Queue transcription task
from services.transcription_worker import process_transcription
background_tasks.add_task(process_transcription, db_evidence.id)
return db_evidence
return db_evidence
@router.put("/evidence/{evidence_id}", response_model=schemas.Evidence)
def update_evidence(
evidence_id: int,
evidence: schemas.EvidenceUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
db_evidence = db.query(Evidence).filter(Evidence.id == evidence_id).first()
if not db_evidence:
raise HTTPException(status_code=404, detail="Evidence not found")
update_data = evidence.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(db_evidence, key, value)
db.commit()
db.refresh(db_evidence)
return db_evidence
@router.delete("/evidence/{evidence_id}")
def delete_evidence(
evidence_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
db_evidence = db.query(Evidence).filter(Evidence.id == evidence_id).first()
if not db_evidence:
raise HTTPException(status_code=404, detail="Evidence not found")
# Optional: Delete file from disk
if db_evidence.file_path and os.path.exists(db_evidence.file_path):
try:
os.remove(db_evidence.file_path)
except:
pass
db.delete(db_evidence)
db.commit()
return {"detail": "Evidence deleted"}

25
backend/routers/auth.py Normal file
View File

@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from database import get_db
from models import User
from security import verify_password, create_access_token
import schemas
from datetime import timedelta
router = APIRouter(tags=["Authentication"])
@router.post("/token", response_model=schemas.Token)
def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=300)
access_token = create_access_token(
data={"sub": user.email}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}

View File

@ -0,0 +1,75 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Optional
from database import get_db
from models import Contractor
from security import get_current_active_user
import schemas
router = APIRouter(
prefix="/contractors",
tags=["Contractors"],
dependencies=[Depends(get_current_active_user)]
)
@router.post("/", response_model=schemas.Contractor)
def create_contractor(contractor: schemas.ContractorCreate, db: Session = Depends(get_db)):
db_contractor = Contractor(**contractor.dict())
db.add(db_contractor)
db.commit()
db.refresh(db_contractor)
return db_contractor
@router.get("/", response_model=List[schemas.Contractor])
def read_contractors(
parent_id: Optional[int] = None,
only_parents: bool = False,
is_active: Optional[bool] = None,
db: Session = Depends(get_db)
):
query = db.query(Contractor)
if only_parents:
query = query.filter(Contractor.parent_id == None)
elif parent_id is not None:
query = query.filter(Contractor.parent_id == parent_id)
if is_active is not None:
query = query.filter(Contractor.is_active == is_active)
return query.all()
@router.get("/{contractor_id}", response_model=schemas.Contractor)
def read_contractor(contractor_id: int, db: Session = Depends(get_db)):
db_contractor = db.query(Contractor).filter(Contractor.id == contractor_id).first()
if not db_contractor:
raise HTTPException(status_code=404, detail="Contractor not found")
return db_contractor
@router.put("/{contractor_id}", response_model=schemas.Contractor)
@router.patch("/{contractor_id}", response_model=schemas.Contractor)
def update_contractor(
contractor_id: int,
contractor: schemas.ContractorUpdate,
db: Session = Depends(get_db)
):
db_contractor = db.query(Contractor).filter(Contractor.id == contractor_id).first()
if not db_contractor:
raise HTTPException(status_code=404, detail="Contractor not found")
update_data = contractor.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(db_contractor, key, value)
db.commit()
db.refresh(db_contractor)
return db_contractor
@router.delete("/{contractor_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_contractor(contractor_id: int, db: Session = Depends(get_db)):
db_contractor = db.query(Contractor).filter(Contractor.id == contractor_id).first()
if not db_contractor:
raise HTTPException(status_code=404, detail="Contractor not found")
# Optional: instead of hard delete, maybe just deactivate
db.delete(db_contractor)
db.commit()
return None

View File

@ -0,0 +1,142 @@
import os
import shutil
import uuid
import datetime
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.orm import Session
from typing import List, Optional
from database import get_db
from models import NonConformity, User, Activity, Evidence
from security import get_current_active_user
import schemas
router = APIRouter(
prefix="/non-conformities",
tags=["Non-Conformities"]
)
UPLOAD_DIR = "uploads"
if not os.path.exists(UPLOAD_DIR):
os.makedirs(UPLOAD_DIR)
@router.post("/", response_model=schemas.NonConformity)
def create_nc(
nc: schemas.NonConformityCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
db_activity = db.query(Activity).filter(Activity.id == nc.activity_id).first()
if not db_activity:
raise HTTPException(status_code=404, detail="Activity not found")
db_nc = NonConformity(**nc.dict())
db.add(db_nc)
db.commit()
db.refresh(db_nc)
return db_nc
@router.get("/", response_model=List[schemas.NonConformity])
def read_ncs(
activity_id: Optional[int] = None,
status: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
query = db.query(NonConformity)
if activity_id:
query = query.filter(NonConformity.activity_id == activity_id)
if status:
query = query.filter(NonConformity.status == status)
return query.all()
@router.get("/{nc_id}", response_model=schemas.NonConformity)
def read_nc(
nc_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
db_nc = db.query(NonConformity).filter(NonConformity.id == nc_id).first()
if db_nc is None:
raise HTTPException(status_code=404, detail="Non-Conformity not found")
return db_nc
@router.put("/{nc_id}", response_model=schemas.NonConformity)
@router.patch("/{nc_id}", response_model=schemas.NonConformity)
def update_nc(
nc_id: int,
nc: schemas.NonConformityUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
db_nc = db.query(NonConformity).filter(NonConformity.id == nc_id).first()
if not db_nc:
raise HTTPException(status_code=404, detail="Non-Conformity not found")
update_data = nc.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(db_nc, key, value)
db.commit()
db.refresh(db_nc)
return db_nc
@router.post("/{nc_id}/upload", response_model=schemas.Evidence)
async def upload_nc_evidence(
nc_id: int,
file: UploadFile = File(...),
description: Optional[str] = None,
captured_at: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
# Verify NC exists
db_nc = db.query(NonConformity).filter(NonConformity.id == nc_id).first()
if not db_nc:
raise HTTPException(status_code=404, detail="Non-Conformity not found")
# Generate unique filename
file_ext = os.path.splitext(file.filename)[1]
unique_filename = f"nc_{uuid.uuid4()}{file_ext}"
file_path = os.path.join(UPLOAD_DIR, unique_filename)
# Save file
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
db_captured_at = None
if captured_at:
try:
db_captured_at = datetime.datetime.fromisoformat(captured_at.replace('Z', '+00:00'))
except:
db_captured_at = datetime.datetime.utcnow()
else:
db_captured_at = datetime.datetime.utcnow()
# Save to database
db_evidence = Evidence(
non_conformity_id=nc_id,
file_path=file_path,
media_type=file.content_type,
description=description,
captured_at=db_captured_at
)
db.add(db_evidence)
db.commit()
db.refresh(db_evidence)
return db_evidence
@router.delete("/{nc_id}")
def delete_nc(
nc_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
db_nc = db.query(NonConformity).filter(NonConformity.id == nc_id).first()
if not db_nc:
raise HTTPException(status_code=404, detail="Non-Conformity not found")
db.delete(db_nc)
db.commit()
return {"detail": "Non-Conformity deleted"}

View File

@ -0,0 +1,95 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models import Project, Specialty, Contractor
from security import get_current_active_user
import schemas
router = APIRouter(
prefix="/projects",
tags=["Projects"],
dependencies=[Depends(get_current_active_user)]
)
@router.post("/", response_model=schemas.Project)
def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)):
db_project = db.query(Project).filter(Project.code == project.code).first()
if db_project:
raise HTTPException(status_code=400, detail="Project code already exists")
project_data = project.dict(exclude={'specialty_ids', 'contractor_ids'})
db_project = Project(**project_data)
# Handle Parent Project
if project.parent_id:
parent = db.query(Project).filter(Project.id == project.parent_id).first()
if not parent:
raise HTTPException(status_code=404, detail="Parent project not found")
if project.specialty_ids:
specialties = db.query(Specialty).filter(Specialty.id.in_(project.specialty_ids)).all()
db_project.specialties = specialties
# Handle Contractors
if project.contractor_ids:
contractors = db.query(Contractor).filter(Contractor.id.in_(project.contractor_ids)).all()
db_project.contractors = contractors
db.add(db_project)
db.commit()
db.refresh(db_project)
return db_project
@router.get("/", response_model=List[schemas.Project])
def read_projects(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
projects = db.query(Project).offset(skip).limit(limit).all()
return projects
@router.get("/{project_id}", response_model=schemas.Project)
def read_project(project_id: int, db: Session = Depends(get_db)):
db_project = db.query(Project).filter(Project.id == project_id).first()
if db_project is None:
raise HTTPException(status_code=404, detail="Project not found")
return db_project
@router.put("/{project_id}", response_model=schemas.Project)
def update_project(project_id: int, project: schemas.ProjectCreate, db: Session = Depends(get_db)):
db_project = db.query(Project).filter(Project.id == project_id).first()
if db_project is None:
raise HTTPException(status_code=404, detail="Project not found")
# Update simple fields
for key, value in project.dict(exclude={'specialty_ids', 'contractor_ids'}).items():
setattr(db_project, key, value)
# Handle Parent Project
if project.parent_id is not None:
parent = db.query(Project).filter(Project.id == project.parent_id).first()
if not parent and project.parent_id != 0: # Allow 0 or null to clear? Actually null is enough
raise HTTPException(status_code=404, detail="Parent project not found")
db_project.parent_id = project.parent_id if project.parent_id != 0 else None
# Update Specialties
if project.specialty_ids is not None:
specialties = db.query(Specialty).filter(Specialty.id.in_(project.specialty_ids)).all()
db_project.specialties = specialties
# Update Contractors
if project.contractor_ids is not None:
contractors = db.query(Contractor).filter(Contractor.id.in_(project.contractor_ids)).all()
db_project.contractors = contractors
db.commit()
db.refresh(db_project)
return db_project
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_project(project_id: int, db: Session = Depends(get_db)):
db_project = db.query(Project).filter(Project.id == project_id).first()
if db_project is None:
raise HTTPException(status_code=404, detail="Project not found")
db.delete(db_project)
db.commit()
return None

View File

@ -0,0 +1,17 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models import Specialty
from security import get_current_active_user
import schemas
router = APIRouter(
prefix="/specialties",
tags=["Specialties"],
dependencies=[Depends(get_current_active_user)]
)
@router.get("/", response_model=List[schemas.Specialty])
def read_specialties(db: Session = Depends(get_db)):
return db.query(Specialty).all()

View File

@ -0,0 +1,54 @@
import os
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
from security import get_current_active_user
import google.generativeai as genai
import tempfile
from dotenv import load_dotenv
load_dotenv()
router = APIRouter(
prefix="/transcription",
tags=["Transcription"],
dependencies=[Depends(get_current_active_user)]
)
# Initialize Google Gemini
api_key = os.getenv("GOOGLE_API_KEY")
if api_key:
genai.configure(api_key=api_key)
@router.post("/")
async def transcribe_audio(file: UploadFile = File(...)):
if not os.getenv("GOOGLE_API_KEY"):
# Mock transcription for development if no key is present
return {"text": f"[MOCK GEMINI TRANSCRIPTION] Se ha recibido un archivo de audio de tipo {file.content_type}. Configure GOOGLE_API_KEY para transcripción real con Gemini."}
try:
# Create a temporary file to store the upload
suffix = os.path.splitext(file.filename)[1] or ".wav"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
# Upload to Gemini (Media Service)
audio_file = genai.upload_file(path=tmp_path, mime_type=file.content_type or "audio/wav")
# Use Gemini 1.5 Flash for audio-to-text
model = genai.GenerativeModel("gemini-2.5-flash-lite")
response = model.generate_content([
"Por favor, transcribe exactamente lo que se dice en este audio. Solo devuelve el texto transcrito.",
audio_file
])
# Cleanup
os.unlink(tmp_path)
# Gemini files are ephemeral but we can delete explicitly if needed
# genai.delete_file(audio_file.name)
return {"text": response.text}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

27
backend/routers/users.py Normal file
View File

@ -0,0 +1,27 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models import User
from security import get_password_hash, get_current_active_user
from services import users
from schemas import UserCreate, User
router = APIRouter(
prefix="/users",
tags=["Users"],
dependencies=[Depends(get_current_active_user)]
)
@router.post("/", response_model=User)
def create_user(user: UserCreate, db: Session = Depends(get_db)):
return users.create_user(db, user)
@router.get("/", response_model=List[User])
def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
db_users = users.get_users(db, skip, limit)
return db_users
@router.get("/me", response_model=User)
def read_users_me(current_user: User = Depends(get_current_active_user)):
return current_user

194
backend/schemas.py Normal file
View File

@ -0,0 +1,194 @@
from pydantic import BaseModel, EmailStr, field_validator
from typing import Optional, List
from datetime import datetime
from models import UserRole, ActivityType, NCLevel, NCType
# Token
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
email: Optional[str] = None
# User
class UserBase(BaseModel):
email: EmailStr
full_name: Optional[str] = None
role: UserRole = UserRole.SUPERVISOR
is_active: bool = True
class UserCreate(UserBase):
password: str
class User(UserBase):
id: int
class Config:
from_attributes = True
# Project
class ProjectBase(BaseModel):
name: str
code: str
location: Optional[str] = None
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
status: str = "active"
parent_id: Optional[int] = None
class ProjectCreate(ProjectBase):
specialty_ids: Optional[List[int]] = []
contractor_ids: Optional[List[int]] = []
class Project(ProjectBase):
id: int
specialties: List['Specialty'] = []
contractors: List['Contractor'] = []
subprojects: List['Project'] = []
class Config:
from_attributes = True
# Specialty
class SpecialtyBase(BaseModel):
name: str
class Specialty(SpecialtyBase):
id: int
class Config:
from_attributes = True
# Contractor
class ContractorBase(BaseModel):
name: str
ruc: Optional[str] = None
contact_name: Optional[str] = None
email: Optional[EmailStr] = None
phone: Optional[str] = None
address: Optional[str] = None
specialty_id: Optional[int] = None
parent_id: Optional[int] = None
is_active: bool = True
@field_validator('ruc', 'contact_name', 'email', 'phone', 'address', 'specialty_id', 'parent_id', mode='before')
@classmethod
def empty_string_to_none(cls, v):
if v == "":
return None
return v
class ContractorCreate(ContractorBase):
pass
class ContractorUpdate(BaseModel):
name: Optional[str] = None
ruc: Optional[str] = None
contact_name: Optional[str] = None
email: Optional[EmailStr] = None
phone: Optional[str] = None
address: Optional[str] = None
specialty_id: Optional[int] = None
parent_id: Optional[int] = None
is_active: Optional[bool] = None
class Contractor(ContractorBase):
id: int
specialty: Optional[Specialty] = None
subcontractors: List['Contractor'] = []
class Config:
from_attributes = True
# Evidence
class EvidenceBase(BaseModel):
file_path: str
media_type: Optional[str] = None
description: Optional[str] = None
captured_at: Optional[datetime] = None
transcription: Optional[str] = None
transcription_status: str = "none"
class Evidence(EvidenceBase):
id: int
activity_id: Optional[int] = None
non_conformity_id: Optional[int] = None
class Config:
from_attributes = True
class EvidenceUpdate(BaseModel):
description: Optional[str] = None
# Activity
class ActivityBase(BaseModel):
project_id: int
specialty_id: int
contractor_id: Optional[int] = None
type: ActivityType = ActivityType.INSPECTION
area: Optional[str] = None
description: Optional[str] = None
observations: Optional[str] = None
audio_transcription: Optional[str] = None
status: str = "completed"
date: Optional[datetime] = None
end_date: Optional[datetime] = None
class ActivityUpdate(BaseModel):
project_id: Optional[int] = None
specialty_id: Optional[int] = None
contractor_id: Optional[int] = None
area: Optional[str] = None
description: Optional[str] = None
observations: Optional[str] = None
status: Optional[str] = None
date: Optional[datetime] = None
end_date: Optional[datetime] = None
class ActivityCreate(ActivityBase):
pass
# NonConformity
class NonConformityBase(BaseModel):
level: NCLevel = NCLevel.MINOR
description: str
status: str = "open"
due_date: Optional[datetime] = None
responsible_person: Optional[str] = None
action_checklist: Optional[List[dict]] = None
nc_type: Optional[NCType] = None
impact_description: Optional[str] = None
closure_description: Optional[str] = None
class NonConformityCreate(NonConformityBase):
activity_id: int
class NonConformityUpdate(BaseModel):
level: Optional[NCLevel] = None
description: Optional[str] = None
status: Optional[str] = None
due_date: Optional[datetime] = None
responsible_person: Optional[str] = None
action_checklist: Optional[List[dict]] = None
nc_type: Optional[NCType] = None
impact_description: Optional[str] = None
closure_description: Optional[str] = None
class NonConformity(NonConformityBase):
id: int
activity_id: int
evidences: List[Evidence] = []
class Config:
from_attributes = True
class Activity(ActivityBase):
id: int
user_id: int
project: Optional[Project] = None
evidences: List[Evidence] = []
non_conformities: List[NonConformity] = []
class Config:
from_attributes = True
Contractor.model_rebuild()

58
backend/security.py Normal file
View File

@ -0,0 +1,58 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from database import get_db
from models import User
import schemas
# Secret key for JWT (should be in env vars in production)
SECRET_KEY = "super_secret_key_for_fritos_fresh_supervision_system"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 300
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
email: str = payload.get("sub")
if email is None:
raise credentials_exception
token_data = schemas.TokenData(email=email)
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.email == token_data.email).first()
if user is None:
raise credentials_exception
return user
async def get_current_active_user(current_user: User = Depends(get_current_user)):
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user

View File

@ -0,0 +1,35 @@
from sqlalchemy.orm import Session
from typing import Optional
from models import Activity, User
from schemas import ActivityCreate
from fastapi import HTTPException
def get_activities(db: Session, current_user: User, project_id: Optional[int] = None,
specialty_id: Optional[int] = None,
skip: int = 0,
limit: int = 100):
query = db.query(Activity)
if project_id:
query = query.filter(Activity.project_id == project_id)
if specialty_id:
query = query.filter(Activity.specialty_id == specialty_id)
activities = query.offset(skip).limit(limit).all()
return activities
def get_activity(db: Session, activity_id: int):
db_activity = db.query(Activity).filter(Activity.id == activity_id).first()
if db_activity is None:
raise HTTPException(status_code=404, detail="Activity not found")
return db_activity
def create_activity(db: Session, activity: ActivityCreate, current_user: User):
db_activity = Activity(
**activity.dict(),
user_id=current_user.id
)
db.add(db_activity)
db.commit()
db.refresh(db_activity)
return db_activity

View File

@ -0,0 +1,71 @@
import os
import time
from sqlalchemy.orm import Session
from models import Evidence
import google.generativeai as genai
from database import SessionLocal
def process_transcription(evidence_id: int):
"""
Background task to transcribe audio.
In a real scenario, this would call a local model runner like Ollama (Whisper)
or an external API like Gemini.
"""
db = SessionLocal()
try:
evidence = db.query(Evidence).filter(Evidence.id == evidence_id).first()
if not evidence:
return
evidence.transcription_status = "processing"
db.commit()
# Simulate local processing or call a local model
# For now, we'll try to use Gemini if available, or a mock
file_path = evidence.file_path
if not os.path.exists(file_path):
evidence.transcription_status = "error"
evidence.transcription = "Error: File not found"
db.commit()
return
api_key = os.getenv("GOOGLE_API_KEY")
if api_key:
try:
genai.configure(api_key=api_key)
# Upload to Gemini (Media Service)
audio_file = genai.upload_file(path=file_path, mime_type=evidence.media_type or "audio/wav")
# Use Gemini 1.5 Flash for audio-to-text
model = genai.GenerativeModel("gemini-2.5-flash-lite")
response = model.generate_content([
"Por favor, transcribe exactamente lo que se dice en este audio. Solo devuelve el texto transcrito.",
audio_file
])
evidence.transcription = response.text
evidence.transcription_status = "completed"
except Exception as e:
evidence.transcription_status = "error"
evidence.transcription = f"Error: {str(e)}"
else:
# Mock transcription if no API key (Local Model Simulation)
time.sleep(5) # Simulate work
evidence.transcription = f"[LOCAL MOCK TRANSCRIPTION] Transcripción asíncrona completada para {os.path.basename(file_path)}"
evidence.transcription_status = "completed"
db.commit()
except Exception as e:
print(f"Transcription error: {e}")
try:
evidence = db.query(Evidence).filter(Evidence.id == evidence_id).first()
if evidence:
evidence.transcription_status = "error"
evidence.transcription = f"Unexpected error: {str(e)}"
db.commit()
except:
pass
finally:
db.close()

24
backend/services/users.py Normal file
View File

@ -0,0 +1,24 @@
from sqlalchemy.orm import Session
from models import User
from security import get_password_hash
from schemas import UserCreate
from fastapi import HTTPException
def get_users(db: Session, skip: int = 0, limit: int = 100):
return db.query(User).offset(skip).limit(limit).all()
def create_user(db: Session, user: UserCreate):
db_user = db.query(User).filter(User.email == user.email).first()
if db_user:
raise HTTPException(status_code=400, detail="Email already registered")
hashed_password = get_password_hash(user.password)
db_user = User(
email=user.email,
hashed_password=hashed_password,
full_name=user.full_name,
role=user.role
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user

BIN
backend/supervision.db Normal file

Binary file not shown.

17
frontend/.editorconfig Normal file
View File

@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

43
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db

4
frontend/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
frontend/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

42
frontend/.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

59
frontend/README.md Normal file
View File

@ -0,0 +1,59 @@
# SupervisionApp
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.0.3.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

84
frontend/angular.json Normal file
View File

@ -0,0 +1,84 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"supervision-app": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "supervision-app:build:production"
},
"development": {
"buildTarget": "supervision-app:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}

9374
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
frontend/package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "supervision-app",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"packageManager": "npm@11.6.2",
"dependencies": {
"@angular/cdk": "^21.0.0",
"@angular/common": "^21.0.0",
"@angular/compiler": "^21.0.0",
"@angular/core": "^21.0.0",
"@angular/forms": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0",
"lucide-angular": "^0.561.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.0.3",
"@angular/cli": "^21.0.3",
"@angular/compiler-cli": "^21.0.0",
"jsdom": "^27.1.0",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,19 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, LOCALE_ID } from '@angular/core';
import { registerLocaleData } from '@angular/common';
import localeEs from '@angular/common/locales/es';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './interceptors/auth-interceptor';
registerLocaleData(localeEs);
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
{ provide: LOCALE_ID, useValue: 'es' }
]
};

View File

@ -0,0 +1 @@
<router-outlet></router-outlet>

View File

@ -0,0 +1,38 @@
import { Routes } from '@angular/router';
import { LoginComponent } from './components/login/login';
import { LayoutComponent } from './components/layout/layout';
import { DashboardComponent } from './components/dashboard/dashboard';
import { ActivityListComponent } from './components/activity-list/activity-list';
import { ActivityFormComponent } from './components/activity-form/activity-form';
import { ProjectListComponent } from './components/project-list/project-list';
import { ProjectFormComponent } from './components/project-form/project-form';
import { NonConformityListComponent } from './components/non-conformity-list/non-conformity-list';
import { NonConformityFormComponent } from './components/non-conformity-form/non-conformity-form';
import { ContractorListComponent } from './components/contractor-list/contractor-list';
import { ContractorFormComponent } from './components/contractor-form/contractor-form';
import { authGuard } from './guards/auth';
export const routes: Routes = [
{ path: 'login', component: LoginComponent },
{
path: '',
component: LayoutComponent,
canActivate: [authGuard],
children: [
{ path: 'dashboard', component: DashboardComponent },
{ path: 'activities', component: ActivityListComponent },
{ path: 'activities/new', component: ActivityFormComponent },
{ path: 'activities/edit/:id', component: ActivityFormComponent },
{ path: 'projects', component: ProjectListComponent },
{ path: 'projects/new', component: ProjectFormComponent },
{ path: 'projects/edit/:id', component: ProjectFormComponent },
{ path: 'non-conformities', component: NonConformityListComponent },
{ path: 'non-conformities/edit/:id', component: NonConformityFormComponent },
{ path: 'contractors', component: ContractorListComponent },
{ path: 'contractors/new', component: ContractorFormComponent },
{ path: 'contractors/edit/:id', component: ContractorFormComponent },
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' }
]
},
{ path: '**', redirectTo: 'login' }
];

View File

View File

@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, supervision-app');
});
});

19
frontend/src/app/app.ts Normal file
View File

@ -0,0 +1,19 @@
import { Component, inject, OnInit } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { AuthService } from './services/auth';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.scss'
})
export class App implements OnInit {
private authService = inject(AuthService);
ngOnInit() {
if (this.authService.token()) {
this.authService.fetchCurrentUser().subscribe();
}
}
}

View File

@ -0,0 +1,60 @@
<div class="calendar-wrapper">
<!-- Calendar Header -->
<div class="calendar-header">
<div class="nav-controls">
<button class="nav-btn" (click)="prevWeek()">
<lucide-icon [img]="ChevronLeft" size="20"></lucide-icon>
</button>
<button class="today-btn" (click)="goToToday()">Hoy</button>
<button class="nav-btn" (click)="nextWeek()">
<lucide-icon [img]="ChevronRight" size="20"></lucide-icon>
</button>
</div>
<div class="current-month">
<h2>{{ currentDate() | date:'MMMM yyyy' | titlecase }}</h2>
</div>
<div class="view-info">
<span class="week-label">Semana del {{ weekDays()[0] | date:'shortDate' }} al {{ weekDays()[6] |
date:'shortDate' }}</span>
</div>
</div>
<!-- Week Grid -->
<div class="week-grid">
<div class="day-column" *ngFor="let day of weekDays(); let i = index"
[class.is-today]="(day | date:'shortDate') === (currentDate() | date:'shortDate')">
<div class="day-header">
<span class="day-name">{{ day | date:'EEE' | uppercase }}</span>
<span class="day-number">{{ day | date:'dd' }}</span>
</div>
<div class="day-body">
<div class="activity-cards">
<div class="activity-card" *ngFor="let act of getActivitiesForDay(day)"
[class]="getActivityClass(act.type)" [routerLink]="['/activities/edit', act.id]">
<div class="time" *ngIf="act.date">
<lucide-icon [img]="Clock" size="10"></lucide-icon>
<span>{{ act.date | date:'HH:mm' }}</span>
</div>
<h4 class="act-area">{{ act.area }}</h4>
<p class="act-desc">{{ act.description | slice:0:40 }}{{ act.description.length > 40 ? '...' :
'' }}</p>
<div class="card-footer">
<span class="type-badge">{{ getActivityTypeName(act.type) }}</span>
</div>
</div>
<div class="add-slot" [routerLink]="['/activities/new']"
[queryParams]="{ date: day.toISOString() }">
<lucide-icon [img]="MoreHorizontal" size="14"></lucide-icon>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,277 @@
.calendar-wrapper {
background: var(--bg-surface);
border-radius: 20px;
padding: 24px;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
.nav-controls {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-main);
padding: 4px;
border-radius: 12px;
border: 1px solid var(--border-color);
.nav-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--text-muted);
transition: all 0.2s;
&:hover {
background: white;
color: var(--brand-primary);
}
}
.today-btn {
padding: 0 16px;
height: 36px;
font-size: 0.85rem;
font-weight: 700;
color: var(--brand-secondary);
&:hover {
color: var(--brand-primary);
}
}
}
.current-month h2 {
font-size: 1.25rem;
margin: 0;
color: var(--brand-secondary);
font-weight: 800;
text-transform: capitalize;
}
.week-label {
font-size: 0.85rem;
color: var(--text-muted);
font-weight: 600;
}
}
.week-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
background: var(--border-color);
border: 1px solid var(--border-color);
border-radius: 16px;
overflow: hidden;
@media (max-width: 1024px) {
display: flex;
flex-direction: column;
gap: 12px;
background: transparent;
border: none;
overflow: visible;
}
}
.day-column {
background: var(--bg-surface);
min-height: 500px;
display: flex;
flex-direction: column;
&.is-today {
background: rgba(180, 83, 9, 0.02);
.day-header {
.day-number {
background: var(--brand-primary);
color: white;
}
.day-name {
color: var(--brand-primary);
}
}
}
@media (max-width: 1024px) {
min-height: auto;
border: 1px solid var(--border-color);
border-radius: 16px;
}
}
.day-header {
padding: 16px;
text-align: center;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
.day-name {
font-size: 0.7rem;
font-weight: 800;
color: var(--text-muted);
letter-spacing: 0.05em;
}
.day-number {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 1rem;
font-weight: 700;
color: var(--brand-secondary);
}
}
.day-body {
flex: 1;
padding: 12px;
background: rgba(248, 250, 252, 0.5);
.activity-cards {
display: flex;
flex-direction: column;
gap: 8px;
}
}
.activity-card {
padding: 12px;
border-radius: 10px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
border-left: 4px solid #cbd5e1;
background: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
&:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.time {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.7rem;
font-weight: 700;
color: var(--text-muted);
margin-bottom: 4px;
}
h4 {
margin: 0;
font-size: 0.85rem;
font-weight: 800;
color: var(--brand-secondary);
margin-bottom: 2px;
}
p {
margin: 0;
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1.2;
}
.card-footer {
margin-top: 8px;
.type-badge {
font-size: 0.6rem;
text-transform: uppercase;
font-weight: 800;
padding: 2px 6px;
border-radius: 4px;
background: #f1f5f9;
}
}
// Color variants
&.type-inspection {
border-left-color: #3b82f6;
.time {
color: #3b82f6;
}
}
&.type-meeting {
border-left-color: #10b981;
.time {
color: #10b981;
}
}
&.type-virtual {
border-left-color: #8b5cf6;
.time {
color: #8b5cf6;
}
}
&.type-coordination {
border-left-color: #f59e0b;
.time {
color: #f59e0b;
}
}
&.type-test {
border-left-color: #ef4444;
.time {
color: #ef4444;
}
}
&.type-other {
border-left-color: #64748b;
}
}
.add-slot {
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--border-color);
border-radius: 8px;
color: var(--text-muted);
opacity: 0;
transition: opacity 0.2s;
&:hover {
background: white;
color: var(--brand-primary);
border-color: var(--brand-primary);
}
}
.day-column:hover .add-slot {
opacity: 1;
}

View File

@ -0,0 +1,109 @@
import { Component, inject, signal, OnInit, computed, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, ChevronLeft, ChevronRight, Calendar as CalendarIcon, Clock, MoreHorizontal } from 'lucide-angular';
import { ActivityService } from '../../services/activity';
import { RouterModule } from '@angular/router';
@Component({
selector: 'app-activity-calendar',
standalone: true,
imports: [CommonModule, LucideAngularModule, RouterModule],
templateUrl: './activity-calendar.html',
styleUrl: './activity-calendar.scss'
})
export class ActivityCalendarComponent implements OnInit {
readonly ChevronLeft = ChevronLeft;
readonly ChevronRight = ChevronRight;
readonly CalendarIcon = CalendarIcon;
readonly Clock = Clock;
readonly MoreHorizontal = MoreHorizontal;
private activityService = inject(ActivityService);
@Input() selectedProjectId: number | null = null;
activities = signal<any[]>([]);
currentDate = signal(new Date());
weekDays = signal<Date[]>([]);
loading = signal(false);
ngOnInit() {
this.generateWeekDays();
this.loadActivities();
}
generateWeekDays() {
const curr = new Date(this.currentDate());
const first = curr.getDate() - curr.getDay() + 1; // Monday as first day
const days = [];
for (let i = 0; i < 7; i++) {
const d = new Date(curr.setDate(first + i));
days.push(new Date(d));
}
this.weekDays.set(days);
}
loadActivities() {
this.loading.set(true);
this.activityService.getActivities(this.selectedProjectId || undefined).subscribe({
next: (acts) => {
this.activities.set(acts);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
nextWeek() {
const d = new Date(this.currentDate());
d.setDate(d.getDate() + 7);
this.currentDate.set(d);
this.generateWeekDays();
}
prevWeek() {
const d = new Date(this.currentDate());
d.setDate(d.getDate() - 7);
this.currentDate.set(d);
this.generateWeekDays();
}
goToToday() {
this.currentDate.set(new Date());
this.generateWeekDays();
}
getActivitiesForDay(day: Date) {
return this.activities().filter(a => {
const actDate = new Date(a.date);
return actDate.getDate() === day.getDate() &&
actDate.getMonth() === day.getMonth() &&
actDate.getFullYear() === day.getFullYear();
});
}
getActivityClass(type: string) {
const classes: any = {
'inspection': 'type-inspection',
'meeting': 'type-meeting',
'virtual_meeting': 'type-virtual',
'coordination': 'type-coordination',
'test': 'type-test',
'other': 'type-other'
};
return classes[type] || 'type-other';
}
getActivityTypeName(type: string) {
const names: any = {
'inspection': 'Supervisión',
'meeting': 'Reunión Presencial',
'virtual_meeting': 'Reunión Virtual',
'coordination': 'Coordinación',
'test': 'Pruebas/Ensayos',
'other': 'Otro'
};
return names[type] || type;
}
}

View File

@ -0,0 +1,285 @@
<div class="page-header sticky-header">
<div class="header-left">
<button class="back-btn" routerLink="/activities" type="button">
<lucide-icon [img]="ArrowLeft" size="20"></lucide-icon>
</button>
<div class="title-meta">
<h1>{{ editMode() ? 'Editar Registro' : 'Nuevo Registro' }}</h1>
<span class="badge" *ngIf="editMode()">ID: #{{ activityId }}</span>
</div>
</div>
<div class="header-actions">
<button type="button" class="cancel-btn" routerLink="/activities">Cancelar</button>
<button type="submit" form="activityForm" class="gold-button" [disabled]="loading() || activityForm.invalid">
<span *ngIf="!loading()">{{ editMode() ? 'Guardar Cambios' : 'Guardar' }}</span>
<span *ngIf="loading()">{{ editMode() ? 'Procesando...' : 'Guardando...' }}</span>
</button>
</div>
</div>
<div class="form-container">
<!-- Activity Status (Visible in Edit Mode) -->
<div class="premium-card status-selector-card" *ngIf="editMode()">
<div class="status-options">
<button type="button" class="status-btn pending"
[class.active]="activityForm.get('status')?.value === 'pending'"
(click)="activityForm.patchValue({status: 'pending'})">Pendiente</button>
<button type="button" class="status-btn in-progress"
[class.active]="activityForm.get('status')?.value === 'in-progress'"
(click)="activityForm.patchValue({status: 'in-progress'})">En Proceso</button>
<button type="button" class="status-btn done" [class.active]="activityForm.get('status')?.value === 'done'"
(click)="activityForm.patchValue({status: 'done'})">Finalizado</button>
</div>
</div>
<form [formGroup]="activityForm" (ngSubmit)="onSubmit()" id="activityForm" class="premium-card">
<div class="form-grid">
<div class="form-group">
<label>Proyecto</label>
<select formControlName="project_id">
<option [value]="null" disabled>Seleccione un proyecto</option>
<option *ngFor="let p of projects()" [value]="p.id">{{ p.name }}</option>
</select>
</div>
<div class="form-group">
<label>Tipo de Actividad</label>
<select formControlName="type">
<option value="inspection">Supervisión / Inspección</option>
<option value="meeting">Reunión Presencial</option>
<option value="virtual_meeting">Reunión Virtual de Coordinación</option>
<option value="coordination">Coordinación General</option>
<option value="test">Pruebas / Ensayos</option>
<option value="other">Otro</option>
</select>
</div>
<div class="form-group">
<label>Especialidad</label>
<select formControlName="specialty_id">
<option value="1">Civil</option>
<option value="2">Mecánica</option>
<option value="3">Eléctrica</option>
<option value="4">SSOMA</option>
</select>
</div>
<div class="form-group">
<label>Fecha y Hora Inicio</label>
<input type="datetime-local" formControlName="date">
</div>
<div class="form-group">
<label>Fecha y Hora Fin (Opcional)</label>
<input type="datetime-local" formControlName="end_date">
</div>
<div class="form-group full-width">
<label>Área / Frente / Línea</label>
<input type="text" formControlName="area" placeholder="Ej: Zona Norte, Línea 04...">
</div>
<div class="form-group full-width">
<label>Descripción de la Actividad</label>
<textarea formControlName="description" rows="4" placeholder="Describa lo observado..."></textarea>
</div>
</div>
<div class="evidence-section">
<div class="section-header">
<h3>Evidencias</h3>
<p>Gestione los registros técnicos vinculados a esta actividad.</p>
</div>
<!-- Existing Evidences -->
<div class="existing-evidences" *ngIf="editMode() && existingEvidences.length > 0">
<div class="section-sub-header">
<h4>Archivos Registrados ({{ existingEvidences.length }})</h4>
<button type="button" class="refresh-btn" (click)="refreshStatus()" title="Actualizar">
<lucide-icon [img]="RefreshCw" size="16" [class.animate-spin]="loading()"></lucide-icon>
</button>
</div>
<div class="evidence-grid-dynamic">
<div class="evidence-card-editable" *ngFor="let ev of existingEvidences">
<div class="card-media" (click)="openPreview(ev)">
<img [src]="'http://192.168.1.74:8000/' + ev.file_path"
*ngIf="ev.media_type.startsWith('image/')">
<div class="placeholder audio" *ngIf="ev.media_type.startsWith('audio/')">
<lucide-icon [img]="FileAudio" size="32"></lucide-icon>
</div>
<div class="placeholder video" *ngIf="ev.media_type.startsWith('video/')">
<lucide-icon [img]="Play" size="32"></lucide-icon>
</div>
<div class="overlay-play">
<lucide-icon [img]="Maximize2" size="20"></lucide-icon>
</div>
</div>
<div class="card-content">
<div class="card-header">
<span class="type-tag">{{ ev.media_type.split('/')[1] }}</span>
<button type="button" class="delete-btn" (click)="deleteExistingEvidence(ev.id)">
<lucide-icon [img]="Trash2" size="14"></lucide-icon>
</button>
</div>
<textarea [(ngModel)]="ev.description" [ngModelOptions]="{standalone: true}"
(blur)="updateEvidenceDescription(ev)" placeholder="Añadir descripción..."></textarea>
<div class="transcription-status" *ngIf="ev.transcription_status !== 'none'">
<div [class]="'status-badge ' + ev.transcription_status">
<lucide-icon
[img]="ev.transcription_status === 'completed' ? CheckCircle : (ev.transcription_status === 'error' ? AlertTriangle : Clock)"
size="12"></lucide-icon>
<span>{{ ev.transcription_status === 'completed' ? 'Transcrito' :
ev.transcription_status === 'error' ? 'Error' : 'Procesando' }}</span>
</div>
<button type="button" class="retry-btn" *ngIf="ev.transcription_status === 'error'"
(click)="retryTranscription(ev.id)">Reintentar</button>
<p class="transcription-text" *ngIf="ev.transcription_status === 'completed'">
"{{ ev.transcription }}"
</p>
</div>
</div>
</div>
</div>
</div>
<div class="upload-area">
<div class="upload-main" (click)="fileInput.click()">
<lucide-icon [img]="Upload" size="32" class="upload-icon"></lucide-icon>
<div class="upload-text">
<strong>Cargar archivos</strong>
<span>Fotos y Videos</span>
</div>
</div>
<div class="action-buttons-group">
<button type="button" class="camera-btn" (click)="startCamera()">
<lucide-icon [img]="Camera" size="20"></lucide-icon>
Cámara
</button>
<button type="button" class="mic-btn" [class.recording]="isRecording()" (click)="toggleRecording()">
<lucide-icon [img]="isRecording() ? Square : Mic" size="20"></lucide-icon>
</button>
</div>
<input type="file" #fileInput (change)="onFileSelected($event)" multiple hidden
accept="image/*,video/*">
</div>
<div class="preview-list" *ngIf="selectedFiles.length > 0">
<div class="preview-item-expanded" *ngFor="let item of selectedFiles; let i = index">
<div class="preview-media">
<img [src]="item.previewUrl" *ngIf="item.file.type.startsWith('image/')">
<div class="video-placeholder" *ngIf="item.file.type.startsWith('video/')">
<lucide-icon [img]="Shield" size="32"></lucide-icon>
<span>Video</span>
</div>
<div class="audio-placeholder" *ngIf="item.file.type.startsWith('audio/')">
<lucide-icon [img]="FileAudio" size="32"></lucide-icon>
<span>Audio</span>
</div>
</div>
<div class="preview-details">
<div class="preview-header">
<span class="file-name">{{ item.file.name }}</span>
<button type="button" class="remove-btn-small" (click)="removeFile(i)">
<lucide-icon [img]="X" size="14"></lucide-icon>
</button>
</div>
<textarea [(ngModel)]="item.description" [ngModelOptions]="{standalone: true}"
placeholder="Descripción de esta evidencia..."></textarea>
</div>
</div>
</div>
</div>
<!-- Non-Conformities Section -->
<div class="evidence-section nc-section">
<div class="section-header">
<h3>Hallazgos de No Conformidad</h3>
<p>Registre desviaciones o incumplimientos detectados.</p>
</div>
<div class="nc-list">
<div class="nc-item premium-card" *ngFor="let nc of ncEntries(); let i = index">
<div class="nc-header">
<div class="nc-title">
<lucide-icon [img]="AlertTriangle" size="18" [class]="nc.level"></lucide-icon>
<span>Hallazgo #{{ i + 1 }}</span>
</div>
<button type="button" class="remove-nc-btn" (click)="removeNC(i)">
<lucide-icon [img]="X" size="16"></lucide-icon>
</button>
</div>
<div class="nc-fields">
<div class="field-group">
<label>Gravedad</label>
<select [(ngModel)]="nc.level" [ngModelOptions]="{standalone: true}">
<option value="minor">Leve</option>
<option value="major">Grave</option>
<option value="critical">Crítica</option>
</select>
</div>
<div class="field-group textarea-group">
<label>Descripción</label>
<textarea [(ngModel)]="nc.description" [ngModelOptions]="{standalone: true}"
placeholder="Detalles del hallazgo..."></textarea>
</div>
</div>
</div>
<button type="button" class="add-nc-btn" (click)="addNC()">
<lucide-icon [img]="Plus" size="18"></lucide-icon>
<span>Nueva No Conformidad</span>
</button>
</div>
</div>
</form>
</div>
<!-- Preview Modal -->
<div class="preview-modal-overlay" *ngIf="previewModal()" (click)="closePreview()">
<div class="modal-content" (click)="$event.stopPropagation()">
<button class="close-modal" (click)="closePreview()">
<lucide-icon [img]="X" size="24"></lucide-icon>
</button>
<div class="modal-media">
<img [src]="previewModal()?.url" *ngIf="previewModal()?.type?.startsWith('image/')">
<audio controls autoplay *ngIf="previewModal()?.type?.startsWith('audio/')">
<source [src]="previewModal()?.url" [type]="previewModal()?.type">
</audio>
<video controls autoplay *ngIf="previewModal()?.type?.startsWith('video/')">
<source [src]="previewModal()?.url" [type]="previewModal()?.type">
</video>
</div>
<div class="modal-footer">
<p>{{ previewModal()?.description || 'Sin descripción' }}</p>
<a [href]="previewModal()?.url" target="_blank" class="download-link">
<lucide-icon [img]="ExternalLink" size="14"></lucide-icon>
Ver original
</a>
</div>
</div>
</div>
<!-- Camera Overlay -->
<div class="camera-overlay" *ngIf="showCamera()">
<div class="camera-container">
<video id="cameraPreview" autoplay playsinline muted></video>
<div class="camera-actions">
<button type="button" class="action-btn photo" (click)="takePhoto()">Foto</button>
<button type="button" class="action-btn video" [class.recording]="isRecordingVideo()"
(click)="toggleVideoRecording()">
{{ isRecordingVideo() ? 'Detener' : 'Video' }}
</button>
<button type="button" class="action-btn cancel" (click)="stopCamera()">Cerrar</button>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivityForm } from './activity-form';
describe('ActivityForm', () => {
let component: ActivityForm;
let fixture: ComponentFixture<ActivityForm>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ActivityForm]
})
.compileComponents();
fixture = TestBed.createComponent(ActivityForm);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,426 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, Router, ActivatedRoute } from '@angular/router';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators, FormsModule } from '@angular/forms';
import { ActivityService } from '../../services/activity';
import { ProjectService } from '../../services/project';
import { TranscriptionService } from '../../services/transcription';
import { forkJoin } from 'rxjs';
import {
LucideAngularModule, ArrowLeft, Upload, X, Shield, Mic, Square, Loader,
Camera, TriangleAlert, Plus, FileVolume, RefreshCw, CircleCheck, Clock,
Trash2, Maximize2, Play, ExternalLink
} from 'lucide-angular';
import { NonConformityService } from '../../services/non-conformity';
export enum ActivityType {
INSPECTION = 'inspection',
MEETING = 'meeting',
VIRTUAL_MEETING = 'virtual_meeting',
COORDINATION = 'coordination',
TEST = 'test',
OTHER = 'other'
}
interface EvidenceItem {
file: File;
description: string;
capturedAt: string;
previewUrl: string;
}
interface NCEntry {
id?: number;
level: 'critical' | 'major' | 'minor';
description: string;
}
@Component({
selector: 'app-activity-form',
standalone: true,
imports: [CommonModule, RouterModule, ReactiveFormsModule, FormsModule, LucideAngularModule],
templateUrl: './activity-form.html',
styleUrl: './activity-form.scss'
})
export class ActivityFormComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Upload = Upload;
readonly X = X;
readonly Shield = Shield;
readonly Mic = Mic;
readonly Square = Square;
readonly Loader = Loader;
readonly Camera = Camera;
readonly AlertTriangle = TriangleAlert;
readonly Plus = Plus;
readonly FileAudio = FileVolume;
readonly RefreshCw = RefreshCw;
readonly CheckCircle = CircleCheck;
readonly Clock = Clock;
readonly Trash2 = Trash2;
readonly Maximize2 = Maximize2;
readonly Play = Play;
readonly ExternalLink = ExternalLink;
private fb = inject(FormBuilder);
private activityService = inject(ActivityService);
private projectService = inject(ProjectService);
private transcriptionService = inject(TranscriptionService);
private ncService = inject(NonConformityService);
private route = inject(ActivatedRoute);
private router = inject(Router);
activityForm: FormGroup;
projects = signal<any[]>([]);
selectedFiles: EvidenceItem[] = [];
existingEvidences: any[] = [];
ncEntries = signal<NCEntry[]>([]);
loading = signal(false);
editMode = signal(false);
activityId: number | null = null;
// Audio Recording
isRecording = signal(false);
transcriptionLoading = signal(false);
private mediaRecorder: MediaRecorder | null = null;
private audioChunks: Blob[] = [];
// Camera Management
showCamera = signal(false);
isRecordingVideo = signal(false);
private videoStream: MediaStream | null = null;
private cameraRecorder: MediaRecorder | null = null;
private videoChunks: Blob[] = [];
// Preview Modal
previewModal = signal<{ type: string, url: string, description: string } | null>(null);
constructor() {
this.activityForm = this.fb.group({
project_id: [null, Validators.required],
specialty_id: [1, Validators.required],
area: ['', Validators.required],
description: ['', Validators.required],
status: ['pending'],
type: [ActivityType.INSPECTION],
date: [null, Validators.required],
end_date: [null]
});
}
ngOnInit() {
this.loadProjects();
// Check for edit mode
this.activityId = Number(this.route.snapshot.paramMap.get('id'));
if (this.activityId) {
this.editMode.set(true);
this.loadActivity(this.activityId);
} else {
// Set default date to now
const now = new Date();
now.setMinutes(0); // Round to hour
this.activityForm.patchValue({ date: now.toISOString().slice(0, 16) });
}
}
loadProjects() {
this.projectService.getProjects().subscribe(projs => {
this.projects.set(projs);
if (projs.length > 0 && !this.editMode()) {
this.activityForm.patchValue({ project_id: projs[0].id });
}
});
}
loadActivity(id: number) {
this.activityService.getActivity(id).subscribe(act => {
this.activityForm.patchValue({
project_id: act.project_id,
specialty_id: act.specialty_id,
area: act.area,
description: act.description,
status: act.status,
type: act.type,
date: act.date ? new Date(act.date).toISOString().slice(0, 16) : null,
end_date: act.end_date ? new Date(act.end_date).toISOString().slice(0, 16) : null
});
this.existingEvidences = act.evidences || [];
// Load existing NCs
if (act.non_conformities && act.non_conformities.length > 0) {
this.ncEntries.set(act.non_conformities.map((nc: any) => ({
id: nc.id,
level: nc.level,
description: nc.description
})));
}
});
}
async toggleRecording() {
if (this.isRecording()) {
this.stopRecording();
} else {
await this.startRecording();
}
}
async startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.mediaRecorder = new MediaRecorder(stream);
this.audioChunks = [];
this.mediaRecorder.ondataavailable = (event) => {
this.audioChunks.push(event.data);
};
this.mediaRecorder.onstop = async () => {
const audioBlob = new Blob(this.audioChunks, { type: 'audio/wav' });
const file = new File([audioBlob], `audio_${Date.now()}.wav`, { type: 'audio/wav' });
this.addFile(file);
stream.getTracks().forEach(track => track.stop());
};
this.mediaRecorder.start();
this.isRecording.set(true);
} catch (err) {
console.error('No se pudo acceder al micrófono:', err);
alert('Error: No se pudo acceder al micrófono. Verifique los permisos.');
}
}
stopRecording() {
if (this.mediaRecorder && this.isRecording()) {
this.mediaRecorder.stop();
this.isRecording.set(false);
}
}
refreshStatus() {
if (this.activityId) {
this.loadActivity(this.activityId);
}
}
retryTranscription(evidenceId: number) {
this.activityService.retryTranscription(evidenceId).subscribe({
next: () => {
this.refreshStatus();
},
error: (err) => {
console.error('Error al reintentar transcripción:', err);
alert('No se pudo reintentar la transcripción.');
}
});
}
deleteExistingEvidence(evidenceId: number) {
if (confirm('¿Está seguro de eliminar esta evidencia?')) {
this.activityService.deleteEvidence(evidenceId).subscribe({
next: () => {
this.existingEvidences = this.existingEvidences.filter(e => e.id !== evidenceId);
},
error: (err) => {
console.error('Error al eliminar evidencia:', err);
alert('No se pudo eliminar la evidencia.');
}
});
}
}
updateEvidenceDescription(evidence: any) {
this.activityService.updateEvidence(evidence.id, { description: evidence.description }).subscribe({
error: (err) => {
console.error('Error al actualizar descripción:', err);
}
});
}
openPreview(ev: any) {
const baseUrl = 'http://192.168.1.74:8000/';
this.previewModal.set({
type: ev.media_type,
url: baseUrl + ev.file_path,
description: ev.description || ''
});
}
closePreview() {
this.previewModal.set(null);
}
async startCamera() {
try {
this.videoStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: true });
this.showCamera.set(true);
setTimeout(() => {
const videoElement = document.querySelector('video#cameraPreview') as HTMLVideoElement;
if (videoElement) videoElement.srcObject = this.videoStream;
}, 100);
} catch (err) {
console.error('Error al acceder a la cámara:', err);
alert('No se pudo acceder a la cámara.');
}
}
stopCamera() {
if (this.videoStream) {
this.videoStream.getTracks().forEach(track => track.stop());
this.videoStream = null;
}
this.showCamera.set(false);
this.isRecordingVideo.set(false);
}
takePhoto() {
const video = document.querySelector('video#cameraPreview') as HTMLVideoElement;
if (!video) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx?.drawImage(video, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
const file = new File([blob], `photo_${Date.now()}.jpg`, { type: 'image/jpeg' });
this.addFile(file);
this.stopCamera();
}
}, 'image/jpeg', 0.8);
}
toggleVideoRecording() {
if (this.isRecordingVideo()) {
this.cameraRecorder?.stop();
this.isRecordingVideo.set(false);
} else {
this.startVideoRecording();
}
}
private startVideoRecording() {
if (!this.videoStream) return;
this.videoChunks = [];
this.cameraRecorder = new MediaRecorder(this.videoStream);
this.cameraRecorder.ondataavailable = (e) => this.videoChunks.push(e.data);
this.cameraRecorder.onstop = () => {
const blob = new Blob(this.videoChunks, { type: 'video/mp4' });
const file = new File([blob], `video_${Date.now()}.mp4`, { type: 'video/mp4' });
this.addFile(file);
this.stopCamera();
};
this.cameraRecorder.start();
this.isRecordingVideo.set(true);
}
onFileSelected(event: any) {
const files = event.target.files;
if (files) {
for (let i = 0; i < files.length; i++) {
this.addFile(files[i]);
}
}
}
private addFile(file: File) {
const reader = new FileReader();
reader.onload = (e: any) => {
this.selectedFiles.push({
file,
description: '',
capturedAt: new Date().toISOString(),
previewUrl: e.target.result
});
};
reader.readAsDataURL(file);
}
removeFile(index: number) {
this.selectedFiles.splice(index, 1);
}
addNC() {
this.ncEntries.update(list => [...list, { level: 'minor', description: '' }]);
}
removeNC(index: number) {
this.ncEntries.update(list => list.filter((_, i) => i !== index));
}
onSubmit() {
if (this.activityForm.invalid) return;
this.loading.set(true);
const activityData = {
...this.activityForm.value,
project_id: Number(this.activityForm.value.project_id),
specialty_id: Number(this.activityForm.value.specialty_id)
};
if (this.editMode() && this.activityId) {
this.activityService.updateActivity(this.activityId, activityData).subscribe({
next: (res) => this.processPostSave(res.id),
error: () => this.loading.set(false)
});
} else {
this.activityService.createActivity(activityData).subscribe({
next: (res) => this.processPostSave(res.id),
error: () => this.loading.set(false)
});
}
}
private processPostSave(activityId: number) {
const tasks = [];
// Upload Evidences
if (this.selectedFiles.length > 0) {
tasks.push(...this.selectedFiles.map(item =>
this.activityService.uploadEvidence(activityId, item.file, item.description, item.capturedAt)
));
}
// Sync NCs
if (this.ncEntries().length > 0) {
tasks.push(...this.ncEntries().map(nc => {
if (nc.id) {
// Update existing NC
return this.ncService.updateNC(nc.id, {
level: nc.level,
description: nc.description
});
} else {
// Create new NC
return this.ncService.createNC({
...nc,
activity_id: activityId,
status: 'open'
});
}
}));
}
if (tasks.length > 0) {
forkJoin(tasks).subscribe({
next: () => this.afterSubmit(),
error: () => this.afterSubmit()
});
} else {
this.afterSubmit();
}
}
private handleUploads(activityId: number) {
// Deprecated for processPostSave
}
afterSubmit() {
this.loading.set(false);
this.router.navigate(['/activities']);
}
}

View File

@ -0,0 +1,169 @@
<div class="activity-container">
<div class="page-header">
<div class="title-area">
<h1>Tablero de Actividades</h1>
<p>Monitoreo y gestión en tiempo real.</p>
</div>
<div style="display: flex;align-items: center;gap: 1rem;">
<button [class.active]="currentView() === 'board'" (click)="currentView.set('board')" title="Vista Tablero">
<lucide-icon [img]="LayoutDashboard" size="18"></lucide-icon>
</button>
<button [class.active]="currentView() === 'calendar'" (click)="currentView.set('calendar')"
title="Vista Calendario">
<lucide-icon [img]="CalendarIcon" size="18"></lucide-icon>
</button>
<button class="gold-button" routerLink="/activities/new">
<lucide-icon [img]="Plus" size="20"></lucide-icon>
<span>Nueva Actividad</span>
</button>
</div>
</div>
<div class="premium-card filters-card">
<div class="filter-group">
<label>Filtrar por Proyecto</label>
<select [(ngModel)]="selectedProject" (change)="loadActivities()">
<option [value]="null">Todos los proyectos</option>
<option *ngFor="let p of projects()" [value]="p.id">{{ p.name }}</option>
</select>
</div>
<div class="loading-indicator" *ngIf="loading()">
<div class="spinner"></div>
</div>
</div>
<div class="board-view" *ngIf="currentView() === 'board'">
<div class="kanban-board" cdkDropListGroup>
<!-- Column: PENDING -->
<div class="kanban-column">
<div class="column-header">
<div class="title-icon pending">
<lucide-icon [img]="Clock" size="18"></lucide-icon>
<h2>Pendiente</h2>
</div>
<span class="count">{{ pending().length }}</span>
</div>
<div class="column-body" cdkDropList [cdkDropListData]="pending()"
(cdkDropListDropped)="drop($event, 'pending')" id="pending-list">
<div class="kanban-card" *ngFor="let act of pending()" cdkDrag [cdkDragData]="act">
<div class="card-header">
<span class="specialty-badge" [class]="'spec-' + act.specialty_id">
{{ getSpecialtyName(act.specialty_id) }}
</span>
<a [routerLink]="['/activities/edit', act.id]" class="edit-btn" title="Editar">
<lucide-icon [img]="SquarePen" size="14"></lucide-icon>
</a>
</div>
<div class="card-body">
<span class="project-tag">{{ act.project?.name || 'S/P' }}</span>
<h3>{{ act.area }}</h3>
<p>{{ act.description | slice:0:80 }}{{ act.description.length > 80 ? '...' : '' }}</p>
</div>
<div class="card-footer">
<span class="date">{{ act.date | date:'shortDate' }}</span>
<div class="evidence-tag" *ngIf="act.evidences?.length">
<lucide-icon [img]="Image" size="14"></lucide-icon>
<span>{{ act.evidences.length }}</span>
</div>
</div>
</div>
<div class="empty-column-msg" *ngIf="pending().length === 0">
<p>No hay pendientes</p>
</div>
</div>
</div>
<!-- Column: IN PROGRESS -->
<div class="kanban-column">
<div class="column-header">
<div class="title-icon in-progress">
<lucide-icon [img]="Play" size="18"></lucide-icon>
<h2>En Proceso</h2>
</div>
<span class="count">{{ inProgress().length }}</span>
</div>
<div class="column-body" cdkDropList [cdkDropListData]="inProgress()"
(cdkDropListDropped)="drop($event, 'in-progress')" id="in-progress-list">
<div class="kanban-card" *ngFor="let act of inProgress()" cdkDrag [cdkDragData]="act">
<div class="card-header">
<span class="specialty-badge" [class]="'spec-' + act.specialty_id">
{{ getSpecialtyName(act.specialty_id) }}
</span>
<a [routerLink]="['/activities/edit', act.id]" class="edit-btn" title="Editar">
<lucide-icon [img]="SquarePen" size="14"></lucide-icon>
</a>
</div>
<div class="card-body">
<span class="project-tag">{{ act.project?.name || 'S/P' }}</span>
<h3>{{ act.area }}</h3>
<p>{{ act.description | slice:0:80 }}{{ act.description.length > 80 ? '...' : '' }}</p>
</div>
<div class="card-footer">
<span class="date">{{ act.date | date:'shortDate' }}</span>
<div class="evidence-tag" *ngIf="act.evidences?.length">
<lucide-icon [img]="Image" size="14"></lucide-icon>
<span>{{ act.evidences.length }}</span>
</div>
</div>
</div>
<div class="empty-column-msg" *ngIf="inProgress().length === 0">
<p>Mueva algo aquí</p>
</div>
</div>
</div>
<!-- Column: DONE -->
<div class="kanban-column">
<div class="column-header">
<div class="title-icon done">
<lucide-icon [img]="CircleCheck" size="18"></lucide-icon>
<h2>Finalizado</h2>
</div>
<span class="count">{{ done().length }}</span>
</div>
<div class="column-body" cdkDropList [cdkDropListData]="done()"
(cdkDropListDropped)="drop($event, 'done')" id="done-list">
<div class="kanban-card" *ngFor="let act of done()" cdkDrag [cdkDragData]="act">
<div class="card-header">
<span class="specialty-badge" [class]="'spec-' + act.specialty_id">
{{ getSpecialtyName(act.specialty_id) }}
</span>
<a [routerLink]="['/activities/edit', act.id]" class="edit-btn" title="Editar">
<lucide-icon [img]="SquarePen" size="14"></lucide-icon>
</a>
</div>
<div class="card-body">
<span class="project-tag">{{ act.project?.name || 'S/P' }}</span>
<h3>{{ act.area }}</h3>
<p>{{ act.description | slice:0:80 }}{{ act.description.length > 80 ? '...' : '' }}</p>
</div>
<div class="card-footer">
<span class="date">{{ act.date | date:'shortDate' }}</span>
<div class="evidence-tag" *ngIf="act.evidences?.length">
<lucide-icon [img]="Image" size="14"></lucide-icon>
<span>{{ act.evidences.length }}</span>
</div>
</div>
</div>
<div class="empty-column-msg" *ngIf="done().length === 0">
<p>Sin finalizadas</p>
</div>
</div>
</div>
</div>
</div>
<div class="calendar-view" *ngIf="currentView() === 'calendar'">
<app-activity-calendar [selectedProjectId]="selectedProject"></app-activity-calendar>
</div>
</div>

View File

@ -0,0 +1,293 @@
.activity-container {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
.title-area {
// h1 {
// font-size: 1.75rem;
// color: var(--brand-secondary);
// margin-bottom: 4px;
// }
p {
color: var(--text-muted);
font-size: 0.95rem;
}
}
@media (max-width: 640px) {
flex-direction: column;
gap: 16px;
}
}
.filters-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
margin-bottom: 32px;
background: var(--bg-surface);
border-radius: 16px;
box-shadow: var(--shadow-sm);
.filter-group {
display: flex;
align-items: center;
gap: 12px;
label {
font-weight: 700;
font-size: 0.8rem;
color: var(--text-muted);
text-transform: uppercase;
}
select {
background: var(--bg-main);
border: 1px solid var(--border-color);
padding: 8px 16px;
border-radius: 10px;
font-size: 0.9rem;
color: var(--text-main);
&:focus {
border-color: var(--brand-primary);
outline: none;
}
}
}
}
.kanban-board {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
align-items: flex-start;
@media (max-width: 1024px) {
grid-template-columns: 1fr;
.kanban-column {
min-height: fit-content;
}
.column-body {
min-height: fit-content;
}
}
}
.kanban-column {
background: #f8fafc;
border-radius: 20px;
display: flex;
flex-direction: column;
min-height: calc(100vh - 350px);
border: 1px solid #e2e8f0;
.column-header {
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #e2e8f0;
.title-icon {
display: flex;
align-items: center;
gap: 10px;
h2 {
font-size: 1rem;
font-weight: 700;
margin: 0;
}
&.pending {
color: #64748b;
}
&.in-progress {
color: #b45309;
}
&.done {
color: #059669;
}
}
.count {
background: rgba(0, 0, 0, 0.05);
padding: 2px 10px;
border-radius: 50px;
font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
}
}
.column-body {
flex: 1;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 100px;
}
}
.kanban-card {
background: white;
border-radius: 14px;
padding: 16px;
border: 1px solid #e2e8f0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
cursor: grab;
transition: all 0.2s;
&:active {
cursor: grabbing;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border-color: var(--brand-primary);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.specialty-badge {
font-size: 0.65rem;
font-weight: 800;
padding: 3px 8px;
border-radius: 6px;
text-transform: uppercase;
&.spec-1 {
background: #eff6ff;
color: #1e40af;
}
&.spec-2 {
background: #fef2f2;
color: #991b1b;
}
&.spec-3 {
background: #f0fdf4;
color: #166534;
}
&.spec-4 {
background: #fffbeb;
color: #92400e;
}
}
.edit-btn {
color: var(--text-muted);
&:hover {
color: var(--brand-primary);
}
}
}
.card-body {
margin-bottom: 16px;
.project-tag {
display: block;
font-size: 0.7rem;
color: var(--brand-primary);
font-weight: 700;
text-transform: uppercase;
margin-bottom: 4px;
opacity: 0.8;
}
h3 {
font-size: 0.95rem;
font-weight: 700;
margin-bottom: 6px;
color: var(--brand-secondary);
}
p {
font-size: 0.85rem;
color: var(--text-muted);
line-height: 1.4;
}
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
.date {
font-size: 0.75rem;
color: var(--text-muted);
}
.evidence-tag {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 600;
}
}
}
// CDK Drag & Drop Styles
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 14px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
.cdk-drag-placeholder {
opacity: 0.3;
border: 2px dashed var(--brand-primary);
border-radius: 14px;
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.column-body.cdk-drop-list-dragging .kanban-card:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.empty-column-msg {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #cbd5e1;
font-size: 0.85rem;
font-weight: 500;
border: 2px dashed #e2e8f0;
border-radius: 14px;
margin: 8px;
max-height: 14rem;
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivityList } from './activity-list';
describe('ActivityList', () => {
let component: ActivityList;
let fixture: ComponentFixture<ActivityList>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ActivityList]
})
.compileComponents();
fixture = TestBed.createComponent(ActivityList);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,107 @@
import { Component, inject, signal, OnInit, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { ActivityService } from '../../services/activity';
import { ProjectService } from '../../services/project';
import { LucideAngularModule, Plus, Image, Inbox, SquarePen, Clock, Play, CircleCheck, Calendar as CalendarIcon, LayoutDashboard } from 'lucide-angular';
import { ActivityCalendarComponent } from '../activity-calendar/activity-calendar';
import {
DragDropModule,
CdkDragDrop
} from '@angular/cdk/drag-drop';
@Component({
selector: 'app-activity-list',
standalone: true,
imports: [CommonModule, RouterModule, FormsModule, LucideAngularModule, DragDropModule, ActivityCalendarComponent],
templateUrl: './activity-list.html',
styleUrl: './activity-list.scss'
})
export class ActivityListComponent implements OnInit {
readonly Plus = Plus;
readonly Image = Image;
readonly Inbox = Inbox;
readonly SquarePen = SquarePen;
readonly Clock = Clock;
readonly Play = Play;
readonly CircleCheck = CircleCheck;
readonly CalendarIcon = CalendarIcon;
readonly LayoutDashboard = LayoutDashboard;
currentView = signal<'board' | 'calendar'>('board');
private activityService = inject(ActivityService);
private projectService = inject(ProjectService);
activities = signal<any[]>([]);
projects = signal<any[]>([]);
selectedProject: number | null = null;
loading = signal(false);
// Kanban Columns
pending = computed(() => this.activities().filter(a => a.status === 'pending' || a.status === 'completed')); // Defaulting 'completed' to some column for now or renaming statuses
inProgress = computed(() => this.activities().filter(a => a.status === 'in-progress'));
done = computed(() => this.activities().filter(a => a.status === 'done'));
// Define valid statuses
readonly STATUSES = ['pending', 'in-progress', 'done'];
ngOnInit() {
this.loadProjects();
this.loadActivities();
}
loadProjects() {
this.projectService.getProjects().subscribe(projs => this.projects.set(projs));
}
loadActivities() {
this.loading.set(true);
this.activityService.getActivities(this.selectedProject || undefined).subscribe({
next: (acts) => {
// Map old status 'completed' to 'done' or 'pending' if needed
const mappedActs = acts.map(a => ({
...a,
status: a.status === 'completed' ? 'done' : a.status
}));
this.activities.set(mappedActs);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
drop(event: CdkDragDrop<any[]>, newStatus: string) {
if (event.previousContainer === event.container) {
// Reordering in same column (local optimization not strictly needed for MVP but good for UX)
// Actually signal-based computed columns make this tricky without local state management
} else {
const item = event.item.data;
const oldStatus = item.status;
// Update locally for immediate feedback
const updatedActs = this.activities().map(a =>
a.id === item.id ? { ...a, status: newStatus } : a
);
this.activities.set(updatedActs);
// Update backend
this.activityService.updateActivity(item.id, { status: newStatus }).subscribe({
error: () => {
// Revert on error
const revertedActs = this.activities().map(a =>
a.id === item.id ? { ...a, status: oldStatus } : a
);
this.activities.set(revertedActs);
alert('Error al actualizar el estado de la actividad.');
}
});
}
}
getSpecialtyName(id: number): string {
const names: any = { 1: 'Civil', 2: 'Mecánica', 3: 'Eléctrica', 4: 'SSOMA' };
return names[id] || 'Desconocida';
}
}

View File

@ -0,0 +1,154 @@
<div class="page-container">
<div class="page-header" [class.sticky-header]="true">
<div class="header-left">
<button class="back-btn" routerLink="/contractors">
<lucide-icon [img]="ArrowLeft" size="20"></lucide-icon>
</button>
<div class="title-meta">
<span class="badge">CONTRATISTA</span>
<h1>{{ contractorId ? 'Editar Contratista' : 'Nuevo Contratista' }}</h1>
</div>
</div>
<div class="header-actions">
<button type="button" class="cancel-btn" routerLink="/contractors">Cancelar</button>
<button type="button" class="gold-button" (click)="onSubmit()"
[disabled]="saving() || contractorForm.invalid">
<lucide-icon [img]="Save" size="18"></lucide-icon>
{{ saving() ? 'Guardando...' : 'Guardar Cambios' }}
</button>
</div>
</div>
<div class="form-container" *ngIf="!loading(); else loader">
<form [formGroup]="contractorForm" class="contractor-edit-form">
<div class="form-row split">
<!-- Info Card -->
<div class="premium-card">
<div class="section-header">
<h3>
<lucide-icon [img]="Building" size="20"></lucide-icon>
Identificación de Empresa
</h3>
</div>
<div class="form-group mb-4">
<label>Nombre de la Empresa</label>
<input type="text" formControlName="name" placeholder="Ej. Constructora Delta S.A.C.">
</div>
<div class="form-row">
<div class="form-group">
<label>RUC</label>
<input type="text" formControlName="ruc" placeholder="20XXXXXXXXX">
</div>
<div class="form-group">
<label>Especialidad</label>
<select formControlName="specialty_id">
<option [value]="null">Seleccione especialidad</option>
<option *ngFor="let s of specialties()" [value]="s.id">{{ s.name }}</option>
</select>
</div>
</div>
<div class="form-group mt-4">
<label>Estado de Cuenta</label>
<div class="status-toggle">
<input type="checkbox" id="activeToggle" formControlName="is_active">
<label for="activeToggle">Empresa Activa</label>
</div>
</div>
</div>
<!-- Contact Card -->
<div class="premium-card">
<div class="section-header">
<h3>
<lucide-icon [img]="User" size="20"></lucide-icon>
Información de Contacto
</h3>
</div>
<div class="form-group mb-4">
<label>Persona de Contacto</label>
<div class="input-with-icon">
<lucide-icon [img]="User" size="18"></lucide-icon>
<input type="text" formControlName="contact_name" placeholder="Nombre completo">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Teléfono</label>
<div class="input-with-icon">
<lucide-icon [img]="Phone" size="18"></lucide-icon>
<input type="text" formControlName="phone" placeholder="+51 9XX XXX XXX">
</div>
</div>
<div class="form-group">
<label>Email</label>
<div class="input-with-icon">
<lucide-icon [img]="Mail" size="18"></lucide-icon>
<input type="email" formControlName="email" placeholder="correo@empresa.com">
</div>
</div>
</div>
<div class="form-group mt-4">
<label>Dirección Fiscal / Oficina</label>
<div class="input-with-icon">
<lucide-icon [img]="MapPin" size="18"></lucide-icon>
<input type="text" formControlName="address" placeholder="Av. Principal 123, Lima">
</div>
</div>
</div>
</div>
<!-- Subcontractors Card -->
<div class="premium-card" *ngIf="contractorId || !contractorForm.get('parent_id')?.value">
<div class="section-header flex-between">
<div>
<h3>
<lucide-icon [img]="Shield" size="20"></lucide-icon>
Subcontratistas Asociados
</h3>
<p>Empresas que trabajan bajo el mando de este contratista.</p>
</div>
</div>
<div class="add-sub-form">
<input type="text" [(ngModel)]="newSubName" [ngModelOptions]="{standalone: true}"
placeholder="Nombre del subcontratista">
<input type="text" [(ngModel)]="newSubRuc" [ngModelOptions]="{standalone: true}" placeholder="RUC">
<button type="button" class="add-btn" (click)="addSubcontractor()">
<lucide-icon [img]="Plus" size="18"></lucide-icon>
Añadir
</button>
</div>
<div class="subs-list">
<div class="sub-item" *ngFor="let sub of subcontractors(); let i = index">
<div class="sub-info">
<span class="sub-name">{{ sub.name }}</span>
<span class="sub-ruc" *ngIf="sub.ruc">RUC: {{ sub.ruc }}</span>
</div>
<button type="button" class="remove-btn" (click)="removeSubcontractor(i)">
<lucide-icon [img]="Trash2" size="18"></lucide-icon>
</button>
</div>
<div class="empty-subs" *ngIf="subcontractors().length === 0">
Aún no hay subcontratistas registrados para esta empresa.
</div>
</div>
</div>
</form>
</div>
<ng-template #loader>
<div class="loading-state">
<div class="spinner"></div>
<p>Cargando información del contratista...</p>
</div>
</ng-template>
</div>

View File

@ -0,0 +1,311 @@
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
position: sticky;
top: -24px;
background: rgba(248, 250, 252, 0.8);
backdrop-filter: blur(12px);
z-index: 1000;
padding: 20px 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.sticky-header {
margin-left: -24px;
margin-right: -24px;
padding-left: 24px;
padding-right: 24px;
border-bottom: 1px solid var(--border-color);
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
.back-btn {
background: white;
border: 1px solid var(--border-color);
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
color: var(--text-muted);
box-shadow: var(--shadow-sm);
transition: all 0.2s;
&:hover {
border-color: var(--brand-primary);
color: var(--brand-primary);
transform: translateX(-2px);
}
}
.title-meta {
h1 {
font-size: 1.5rem;
margin: 0;
color: var(--brand-secondary);
}
.badge {
font-size: 0.75rem;
font-weight: 700;
color: var(--brand-primary);
background: rgba(180, 83, 9, 0.1);
padding: 2px 8px;
border-radius: 6px;
letter-spacing: 0.05em;
}
}
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
.cancel-btn {
background: white;
color: var(--text-muted);
font-weight: 700;
padding: 12px 20px;
font-size: 0.85rem;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
}
}
}
.form-container {
max-width: 1200px;
margin: 0 auto;
padding-bottom: 80px;
}
.contractor-edit-form {
display: flex;
flex-direction: column;
gap: 32px;
}
.premium-card {
background: white;
padding: 32px;
border-radius: 24px;
border: 1px solid var(--border-color);
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.02);
.section-header {
margin-bottom: 24px;
&.flex-between {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
h3 {
font-size: 1.25rem;
margin: 0;
display: flex;
align-items: center;
gap: 10px;
}
p {
margin: 6px 0 0;
font-size: 0.95rem;
color: var(--text-muted);
}
}
label {
display: block;
font-size: 0.75rem;
font-weight: 800;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 10px;
}
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
&.split {
gap: 32px;
@media (max-width: 950px) {
grid-template-columns: 1fr;
}
}
}
.input-with-icon {
position: relative;
lucide-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
}
input {
padding-left: 42px !important;
}
&:focus-within lucide-icon {
color: var(--brand-primary);
}
}
input,
select {
width: 100%;
background: var(--bg-main);
border: 2px solid transparent;
padding: 14px 16px;
border-radius: 14px;
font-size: 0.95rem;
font-weight: 500;
transition: all 0.2s ease;
&:focus {
outline: none;
background: white;
border-color: var(--brand-primary);
box-shadow: 0 0 0 4px rgba(180, 83, 9, 0.1);
}
}
.status-toggle {
display: flex;
align-items: center;
gap: 12px;
input[type="checkbox"] {
width: auto;
cursor: pointer;
}
label {
margin: 0;
cursor: pointer;
text-transform: none;
font-size: 0.95rem;
color: var(--text-main);
}
}
.add-sub-form {
display: grid;
grid-template-columns: 2fr 1fr auto;
gap: 16px;
margin-bottom: 24px;
background: var(--bg-main);
padding: 20px;
border-radius: 16px;
.add-btn {
background: white;
color: var(--brand-primary);
border: 1px solid var(--border-color);
padding: 0 24px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 8px;
&:hover {
border-color: var(--brand-primary);
transform: translateY(-1px);
}
}
}
.subs-list {
display: flex;
flex-direction: column;
gap: 12px;
.sub-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: white;
border: 1px solid var(--border-color);
border-radius: 16px;
transition: all 0.2s;
&:hover {
border-color: var(--brand-primary);
box-shadow: var(--shadow-sm);
}
.sub-name {
font-weight: 700;
color: var(--brand-secondary);
}
.sub-ruc {
font-size: 0.8rem;
color: var(--text-muted);
margin-left: 12px;
}
.remove-btn {
color: #94a3b8;
padding: 8px;
border-radius: 10px;
&:hover {
background: #fee2e2;
color: #ef4444;
}
}
}
.empty-subs {
text-align: center;
padding: 40px;
color: var(--text-muted);
background: #f8fafc;
border-radius: 16px;
border: 2px dashed var(--border-color);
}
}
.loading-state {
text-align: center;
padding: 100px 0;
.spinner {
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top: 4px solid var(--brand-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,161 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, ActivatedRoute, Router } from '@angular/router';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ContractorService } from '../../services/contractor';
import { SpecialtyService } from '../../services/specialty';
import { LucideAngularModule, ArrowLeft, Save, Building2, Phone, Mail, MapPin, User, Shield, Plus, X, Trash2 } from 'lucide-angular';
import { forkJoin } from 'rxjs';
@Component({
selector: 'app-contractor-form',
standalone: true,
imports: [CommonModule, RouterModule, FormsModule, ReactiveFormsModule, LucideAngularModule],
templateUrl: './contractor-form.html',
styleUrl: './contractor-form.scss'
})
export class ContractorFormComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Save = Save;
readonly Building = Building2;
readonly Phone = Phone;
readonly Mail = Mail;
readonly MapPin = MapPin;
readonly User = User;
readonly Shield = Shield;
readonly Plus = Plus;
readonly X = X;
readonly Trash2 = Trash2;
private fb = inject(FormBuilder);
private contractorService = inject(ContractorService);
private specialtyService = inject(SpecialtyService);
private route = inject(ActivatedRoute);
private router = inject(Router);
contractorId: number | null = null;
loading = signal(false);
saving = signal(false);
contractorForm: FormGroup;
specialties = signal<any[]>([]);
allContractors = signal<any[]>([]);
subcontractors = signal<any[]>([]);
newSubName = '';
newSubRuc = '';
constructor() {
this.contractorForm = this.fb.group({
name: ['', Validators.required],
ruc: [''],
contact_name: [''],
email: ['', [Validators.email]],
phone: [''],
address: [''],
specialty_id: [null],
parent_id: [null],
is_active: [true]
});
}
ngOnInit() {
this.loadSpecialties();
this.contractorId = Number(this.route.snapshot.paramMap.get('id'));
if (this.contractorId) {
this.loadContractor(this.contractorId);
}
}
loadSpecialties() {
this.specialtyService.getSpecialties().subscribe(data => this.specialties.set(data));
}
loadContractor(id: number) {
this.loading.set(true);
this.contractorService.getContractor(id).subscribe({
next: (data) => {
this.contractorForm.patchValue(data);
this.subcontractors.set(data.subcontractors || []);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
addSubcontractor() {
if (!this.newSubName) return;
const sub = {
name: this.newSubName,
ruc: this.newSubRuc,
is_active: true,
parent_id: this.contractorId
};
if (this.contractorId) {
// Save immediately if we are in edit mode
this.contractorService.createContractor(sub).subscribe(newSub => {
this.subcontractors.update(list => [...list, newSub]);
this.newSubName = '';
this.newSubRuc = '';
});
} else {
// Temporary add if creating new parent
this.subcontractors.update(list => [...list, sub]);
this.newSubName = '';
this.newSubRuc = '';
}
}
removeSubcontractor(index: number) {
const sub = this.subcontractors()[index];
if (sub.id) {
if (confirm('¿Eliminar subcontratista?')) {
this.contractorService.deleteContractor(sub.id).subscribe(() => {
this.subcontractors.update(list => list.filter((_, i) => i !== index));
});
}
} else {
this.subcontractors.update(list => list.filter((_, i) => i !== index));
}
}
onSubmit() {
if (this.contractorForm.invalid) return;
this.saving.set(true);
const data = this.contractorForm.value;
if (this.contractorId) {
this.contractorService.updateContractor(this.contractorId, data).subscribe({
next: () => this.afterSave(),
error: () => this.saving.set(false)
});
} else {
this.contractorService.createContractor(data).subscribe({
next: (newParent) => {
// If there were temporary subs, update them with the new parent_id
if (this.subcontractors().length > 0) {
const subTasks = this.subcontractors().map(sub =>
this.contractorService.createContractor({ ...sub, parent_id: newParent.id })
);
forkJoin(subTasks).subscribe({
next: () => this.afterSave(),
error: () => this.afterSave()
});
} else {
this.afterSave();
}
},
error: () => this.saving.set(false)
});
}
}
afterSave() {
this.saving.set(false);
this.router.navigate(['/contractors']);
}
}

View File

@ -0,0 +1,87 @@
<div class="page-container">
<div class="page-header">
<div class="header-left">
<h1>Gestión de Contratistas</h1>
<p class="subtitle">Administra empresas contratistas y subcontratistas.</p>
</div>
<div class="header-right">
<button class="gold-button" routerLink="/contractors/new">
<lucide-icon [img]="Plus" size="18"></lucide-icon>
Nuevo Contratista
</button>
</div>
</div>
<!-- Filters & Search -->
<div class="filters-bar premium-card">
<div class="search-box">
<lucide-icon [img]="Search" size="18"></lucide-icon>
<input type="text" [ngModel]="searchTerm()" (ngModelChange)="searchTerm.set($event); applyFilter()"
placeholder="Buscar por nombre, RUC o contacto...">
</div>
</div>
<!-- Grid -->
<div class="contractors-grid" *ngIf="!loading(); else loader">
<div class="contractor-card premium-card clickable" *ngFor="let contractor of filteredContractors()"
[routerLink]="['/contractors/edit', contractor.id]">
<div class="card-header">
<div class="company-brand">
<div class="logo-placeholder">
<lucide-icon [img]="Building" size="24"></lucide-icon>
</div>
<div class="company-info">
<h3>{{ contractor.name }}</h3>
<span class="ruc">RUC: {{ contractor.ruc || 'N/A' }}</span>
</div>
</div>
<div class="status-badge" [class.active]="contractor.is_active">
{{ contractor.is_active ? 'Activo' : 'Inactivo' }}
</div>
</div>
<div class="card-body">
<div class="contact-details">
<div class="detail-item">
<lucide-icon [img]="Users" size="16"></lucide-icon>
<span>{{ contractor.contact_name || 'Sin contacto' }}</span>
</div>
<div class="detail-item" *ngIf="contractor.phone">
<lucide-icon [img]="Phone" size="16"></lucide-icon>
<span>{{ contractor.phone }}</span>
</div>
<div class="detail-item" *ngIf="contractor.email">
<lucide-icon [img]="Mail" size="16"></lucide-icon>
<span class="truncate">{{ contractor.email }}</span>
</div>
</div>
</div>
<div class="card-footer">
<div class="sub-count">
<lucide-icon [img]="ChevronRight" size="16"></lucide-icon>
<span>{{ contractor.subcontractors?.length || 0 }} Subcontratistas</span>
</div>
<div class="actions">
<button class="icon-btn delete" (click)="deleteContractor(contractor.id, $event)">
<lucide-icon [img]="Trash2" size="18"></lucide-icon>
</button>
</div>
</div>
</div>
<!-- Empty State -->
<div class="empty-state" *ngIf="filteredContractors().length === 0">
<lucide-icon [img]="Building" size="48"></lucide-icon>
<h3>No se encontraron contratistas</h3>
<p>Crea un nuevo contratista para comenzar la gestión.</p>
</div>
</div>
<ng-template #loader>
<div class="loading-grid">
<div class="skeleton-card" *ngFor="let i of [1,2,3,4,5,6]"></div>
</div>
</ng-template>
</div>

View File

@ -0,0 +1,254 @@
.page-container {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
.header-left {
h1 {
font-size: 2rem;
margin-bottom: 4px;
}
.subtitle {
color: var(--text-muted);
font-size: 1rem;
}
}
}
.filters-bar {
margin-bottom: 32px;
padding: 16px 24px !important;
.search-box {
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-main);
padding: 12px 18px;
border-radius: 14px;
border: 1px solid var(--border-color);
transition: all 0.2s;
&:focus-within {
border-color: var(--brand-primary);
background: white;
box-shadow: 0 0 0 4px rgba(180, 83, 9, 0.1);
}
lucide-icon {
color: var(--text-muted);
}
input {
border: none;
background: transparent;
width: 100%;
font-size: 0.95rem;
font-weight: 500;
color: var(--text-main);
&:focus {
outline: none;
}
&::placeholder {
color: #94a3b8;
}
}
}
}
.contractors-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 24px;
}
.contractor-card {
display: flex;
flex-direction: column;
padding: 0 !important;
overflow: hidden;
height: 100%;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
transform: translateY(-4px);
box-shadow: 0 20px 40px -8px rgba(0, 0, 0, 0.08);
}
.card-header {
padding: 24px;
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid var(--bg-main);
.company-brand {
display: flex;
gap: 16px;
.logo-placeholder {
width: 52px;
height: 52px;
background: linear-gradient(135deg, var(--brand-secondary) 0%, #334155 100%);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.company-info {
h3 {
font-size: 1.1rem;
margin: 0;
color: var(--brand-secondary);
}
.ruc {
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 600;
}
}
}
.status-badge {
font-size: 0.7rem;
font-weight: 800;
text-transform: uppercase;
padding: 4px 10px;
border-radius: 20px;
background: #f1f5f9;
color: #64748b;
&.active {
background: #ecfdf5;
color: #10b981;
}
}
}
.card-body {
padding: 24px;
flex: 1;
.contact-details {
display: flex;
flex-direction: column;
gap: 12px;
.detail-item {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-main);
font-size: 0.9rem;
font-weight: 500;
lucide-icon {
color: var(--text-muted);
}
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
.card-footer {
padding: 16px 24px;
background: var(--bg-main);
display: flex;
justify-content: space-between;
align-items: center;
.sub-count {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
font-weight: 700;
color: var(--brand-primary);
}
.actions {
.icon-btn {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
background: transparent;
&.delete {
color: #94a3b8;
&:hover {
background: #fee2e2;
color: #ef4444;
}
}
}
}
}
}
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 60px 0;
color: var(--text-muted);
lucide-icon {
margin-bottom: 20px;
color: var(--border-color);
}
h3 {
color: var(--brand-secondary);
margin-bottom: 8px;
}
}
.loading-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 24px;
.skeleton-card {
height: 250px;
background: #e2e8f0;
border-radius: 24px;
animation: pulse 1.5s infinite;
}
}
@keyframes pulse {
0% {
opacity: 0.6;
}
50% {
opacity: 0.3;
}
100% {
opacity: 0.6;
}
}

View File

@ -0,0 +1,76 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { ContractorService } from '../../services/contractor';
import { LucideAngularModule, Plus, Search, Building2, Phone, Mail, MapPin, MoreVertical, Trash2, Edit, ChevronRight, Users } from 'lucide-angular';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-contractor-list',
standalone: true,
imports: [CommonModule, RouterModule, LucideAngularModule, FormsModule],
templateUrl: './contractor-list.html',
styleUrl: './contractor-list.scss'
})
export class ContractorListComponent implements OnInit {
readonly Plus = Plus;
readonly Search = Search;
readonly Building = Building2;
readonly Phone = Phone;
readonly Mail = Mail;
readonly MapPin = MapPin;
readonly MoreVertical = MoreVertical;
readonly Trash2 = Trash2;
readonly Edit = Edit;
readonly ChevronRight = ChevronRight;
readonly Users = Users;
private contractorService = inject(ContractorService);
contractors = signal<any[]>([]);
filteredContractors = signal<any[]>([]);
loading = signal(false);
searchTerm = signal('');
ngOnInit() {
this.loadContractors();
}
loadContractors() {
this.loading.set(true);
// Load only parent contractors by default for the main list
this.contractorService.getContractors(null, undefined, true).subscribe({
next: (data) => {
this.contractors.set(data);
this.applyFilter();
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
applyFilter() {
const term = this.searchTerm().toLowerCase();
if (!term) {
this.filteredContractors.set(this.contractors());
return;
}
this.filteredContractors.set(
this.contractors().filter(c =>
c.name.toLowerCase().includes(term) ||
c.ruc?.toLowerCase().includes(term) ||
c.contact_name?.toLowerCase().includes(term)
)
);
}
deleteContractor(id: number, event: Event) {
event.stopPropagation();
if (confirm('¿Está seguro de eliminar este contratista? Se eliminarán también sus subcontratistas.')) {
this.contractorService.deleteContractor(id).subscribe(() => {
this.loadContractors();
});
}
}
}

View File

@ -0,0 +1,66 @@
<div class="dashboard">
<div class="welcome-section">
<h1>Panel de Control</h1>
<p>Bienvenido de nuevo al sistema de supervisión de FRITOS FRESH.</p>
</div>
<div class="stats-grid">
<div class="premium-card stat-card" routerLink="/projects">
<div class="stat-icon gold">
<lucide-icon [img]="HardHat" size="24"></lucide-icon>
</div>
<div class="stat-info">
<h3>Proyectos Activos</h3>
<span class="value">{{ projectsCount().toString().padStart(2, '0') }}</span>
</div>
</div>
<div class="premium-card stat-card" routerLink="/activities">
<div class="stat-icon blue">
<lucide-icon [img]="ClipboardList" size="24"></lucide-icon>
</div>
<div class="stat-info">
<h3>Actividades Hoy</h3>
<span class="value">{{ activitiesToday().toString().padStart(2, '0') }}</span>
</div>
</div>
<div class="premium-card stat-card">
<div class="stat-icon red">
<lucide-icon [img]="AlertTriangle" size="24"></lucide-icon>
</div>
<div class="stat-info">
<h3>No Conformidades</h3>
<span class="value">{{ nonConformitiesCount().toString().padStart(2, '0') }}</span>
</div>
</div>
</div>
<div class="main-grid">
<div class="premium-card activity-feed">
<div class="card-header">
<h2>Actividades Recientes</h2>
<button class="text-btn" routerLink="/activities">Ver todas</button>
</div>
<div class="feed-list">
<div class="feed-item" *ngFor="let act of recentActivities()">
<div class="dot" [class.active]="act.status !== 'done'"></div>
<div class="item-content">
<div class="header">
<strong>{{ act.area }}</strong>
<span class="time">{{ getTimeAgo(act.date) }}</span>
</div>
<p>{{ act.project?.name || 'S/P' }} - {{ act.description | slice:0:100 }}{{
act.description.length > 100 ? '...' : '' }}</p>
<span class="author">ID Actividad: #{{ act.id }}</span>
</div>
</div>
<div class="empty-feed" *ngIf="recentActivities().length === 0">
<p>No hay actividades registradas aún.</p>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,162 @@
.dashboard {
max-width: 1200px;
margin: 0 auto;
.welcome-section {
margin-bottom: 32px;
h1 {
font-size: 1.8rem;
margin-bottom: 4px;
}
p {
color: var(--text-muted);
font-size: 1rem;
}
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
&.gold {
background: rgba(180, 83, 9, 0.1);
color: var(--brand-primary);
}
&.blue {
background: rgba(59, 130, 246, 0.1);
color: var(--brand-accent);
}
&.red {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
}
.stat-info {
h3 {
font-size: 0.85rem;
color: var(--text-muted);
font-weight: 500;
margin-bottom: 2px;
}
.value {
font-size: 1.5rem;
font-weight: 700;
color: var(--brand-secondary);
}
}
}
.activity-feed {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 {
font-size: 1.1rem;
}
.text-btn {
background: none;
color: var(--brand-primary);
font-size: 0.85rem;
font-weight: 600;
}
}
}
.feed-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.feed-item {
display: flex;
gap: 16px;
.dot {
width: 10px;
height: 10px;
background: var(--border-color);
border-radius: 50%;
margin-top: 6px;
flex-shrink: 0;
&.active {
background: var(--brand-primary);
box-shadow: 0 0 0 4px rgba(180, 83, 9, 0.1);
}
}
.item-content {
flex: 1;
.header {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
strong {
font-size: 0.95rem;
color: var(--brand-secondary);
}
.time {
font-size: 0.75rem;
color: var(--text-muted);
}
}
p {
font-size: 0.9rem;
color: var(--text-main);
margin-bottom: 4px;
}
.author {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 500;
}
}
}
.empty-feed {
padding: 32px;
text-align: center;
background: var(--bg-main);
border-radius: 12px;
border: 2px dashed var(--border-color);
p {
color: var(--text-muted);
font-size: 0.9rem;
font-weight: 500;
}
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Dashboard } from './dashboard';
describe('Dashboard', () => {
let component: Dashboard;
let fixture: ComponentFixture<Dashboard>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Dashboard]
})
.compileComponents();
fixture = TestBed.createComponent(Dashboard);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,68 @@
import { Component, inject, signal, OnInit, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { LucideAngularModule, HardHat, ClipboardList, AlertTriangle } from 'lucide-angular';
import { ActivityService } from '../../services/activity';
import { ProjectService } from '../../services/project';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [CommonModule, LucideAngularModule, RouterModule],
templateUrl: './dashboard.html',
styleUrl: './dashboard.scss',
})
export class DashboardComponent implements OnInit {
readonly HardHat = HardHat;
readonly ClipboardList = ClipboardList;
readonly AlertTriangle = AlertTriangle;
private activityService = inject(ActivityService);
private projectService = inject(ProjectService);
activities = signal<any[]>([]);
projectsCount = signal(0);
// Stats
activitiesToday = computed(() => {
const today = new Date().toDateString();
return this.activities().filter(a => new Date(a.date).toDateString() === today).length;
});
nonConformitiesCount = computed(() => {
// Total NCs across all activities
return this.activities().reduce((acc, a) => acc + (a.non_conformities?.length || 0), 0);
});
recentActivities = computed(() => {
return this.activities()
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 5);
});
ngOnInit() {
this.loadData();
}
loadData() {
this.projectService.getProjects().subscribe(projs => {
this.projectsCount.set(projs.length);
});
this.activityService.getActivities().subscribe(acts => {
this.activities.set(acts);
});
}
getTimeAgo(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.round(diffMs / 60000);
if (diffMin < 60) return `Hace ${diffMin}m`;
const diffHours = Math.round(diffMin / 60);
if (diffHours < 24) return `Hace ${diffHours}h`;
return date.toLocaleDateString();
}
}

View File

@ -0,0 +1,21 @@
<header class="header">
<div class="left-section">
<button class="menu-toggle" (click)="toggleSidebar.emit()">
<lucide-icon [img]="Menu" size="24"></lucide-icon>
</button>
<div class="search-bar">
<lucide-icon [img]="Search" size="18"></lucide-icon>
<input type="text" placeholder="Buscar proyectos o actividades...">
</div>
</div>
<div class="user-info" *ngIf="user()">
<div class="user-details">
<span class="name">{{ user().full_name }}</span>
<span class="role">{{ user().role }}</span>
</div>
<div class="user-avatar">
{{ user().full_name.charAt(0) }}
</div>
</div>
</header>

View File

@ -0,0 +1,122 @@
.header {
height: var(--header-height);
background: var(--bg-surface);
border-bottom: 1px solid var(--border-color);
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 90;
@media (max-width: 768px) {
padding: 0 16px;
}
}
.left-section {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
}
.menu-toggle {
background: transparent;
color: var(--text-main);
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
border-radius: 8px;
&:hover {
background: var(--bg-main);
}
}
.search-bar {
background: var(--bg-main);
border-radius: 10px;
padding: 8px 16px;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
max-width: 400px;
border: 1px solid transparent;
transition: all 0.2s;
&:focus-within {
background: var(--bg-surface);
border-color: var(--brand-primary);
box-shadow: 0 0 0 2px rgba(180, 83, 9, 0.1);
}
lucide-icon {
color: var(--text-muted);
}
input {
background: transparent;
border: none;
color: var(--text-main);
font-size: 0.9rem;
width: 100%;
&:focus {
outline: none;
}
}
@media (max-width: 768px) {
display: none; // Hide search on mobile to save space
}
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
padding: 05px 30px;
background-color: #f0eae6;
border-radius: 0.7rem;
cursor: pointer;
.user-details {
text-align: right;
display: flex;
flex-direction: column;
.name {
font-weight: 600;
font-size: 0.9rem;
}
.role {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
font-weight: 500;
}
@media (max-width: 640px) {
display: none;
}
}
.user-avatar {
width: 38px;
height: 38px;
background: var(--brand-secondary);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.9rem;
}
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Header } from './header';
describe('Header', () => {
let component: Header;
let fixture: ComponentFixture<Header>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Header]
})
.compileComponents();
fixture = TestBed.createComponent(Header);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,21 @@
import { Component, inject, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AuthService } from '../../services/auth';
import { LucideAngularModule, Search, Menu } from 'lucide-angular';
@Component({
selector: 'app-header',
standalone: true,
imports: [CommonModule, LucideAngularModule],
templateUrl: './header.html',
styleUrl: './header.scss'
})
export class HeaderComponent {
readonly Search = Search;
readonly Menu = Menu;
@Output() toggleSidebar = new EventEmitter<void>();
private authService = inject(AuthService);
user = this.authService.currentUser;
}

View File

@ -0,0 +1,14 @@
<div class="layout-wrapper" [class.sidebar-collapsed]="!isSidebarOpen()">
<app-sidebar [isCollapsed]="!isSidebarOpen()" [class.open]="isSidebarOpen()"></app-sidebar>
<div class="main-container">
<app-header (toggleSidebar)="toggleSidebar()"></app-header>
<main class="content-area">
<router-outlet></router-outlet>
</main>
</div>
<!-- Mobile Overlay -->
<div class="sidebar-overlay" *ngIf="isSidebarOpen()" (click)="toggleSidebar()"></div>
</div>

View File

@ -0,0 +1,66 @@
.layout-wrapper {
display: flex;
height: 100vh;
position: relative;
overflow: hidden;
&.sidebar-collapsed {
app-sidebar {
width: var(--nav-width-collapsed);
}
}
}
.main-container {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0; // Prevent flex overflow
height: 100vh;
overflow: hidden;
}
.content-area {
padding: 24px;
flex: 1;
overflow-y: auto;
@media (max-width: 768px) {
padding: 16px;
}
}
.sidebar-overlay {
background: rgba(0, 0, 0, 0.4);
position: fixed;
inset: 0;
z-index: 1000;
display: none; // Hidden by default
@media (max-width: 1024px) {
display: block;
}
}
app-sidebar {
display: block;
width: var(--nav-width);
height: 100vh;
position: sticky;
top: 0;
z-index: 1001;
transition: width 0.3s ease;
@media (max-width: 1024px) {
position: fixed;
left: 0;
top: 0;
bottom: 0;
transform: translateX(-100%);
box-shadow: 20px 0 50px rgba(0, 0, 0, 0.1);
&.open {
transform: translateX(0);
}
}
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Layout } from './layout';
describe('Layout', () => {
let component: Layout;
let fixture: ComponentFixture<Layout>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Layout]
})
.compileComponents();
fixture = TestBed.createComponent(Layout);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,20 @@
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { SidebarComponent } from '../sidebar/sidebar';
import { HeaderComponent } from '../header/header';
@Component({
selector: 'app-layout',
standalone: true,
imports: [CommonModule, RouterModule, SidebarComponent, HeaderComponent],
templateUrl: './layout.html',
styleUrl: './layout.scss'
})
export class LayoutComponent {
isSidebarOpen = signal(true);
toggleSidebar() {
this.isSidebarOpen.update(v => !v);
}
}

View File

@ -0,0 +1,42 @@
<div class="login-wrapper">
<div class="premium-card login-card">
<div class="login-header">
<div class="logo-wrapper">
<lucide-icon [img]="Shield" size="48" class="logo-icon"></lucide-icon>
</div>
<h1>BIENVENIDO</h1>
<p>Sistema de Supervisión FF</p>
</div>
<form (submit)="onSubmit($event)" class="login-form">
<div class="form-group">
<label for="username">Usuario</label>
<div class="input-container">
<input type="email" id="username" name="username" [(ngModel)]="username"
placeholder="admin@fritosfresh.com" required>
</div>
</div>
<div class="form-group">
<label for="password">Contraseña</label>
<div class="input-container">
<input type="password" id="password" name="password" [(ngModel)]="password" placeholder="••••••••"
required>
</div>
</div>
<div class="error-msg" *ngIf="error()">
{{ error() }}
</div>
<button type="submit" class="gold-button w-full" [disabled]="loading()">
<span *ngIf="!loading()">Entrar al Sistema</span>
<span *ngIf="loading()">Validando acceso...</span>
</button>
</form>
<div class="login-footer">
<p>© 2024 FRITOS FRESH - Área de Proyectos</p>
</div>
</div>
</div>

View File

@ -0,0 +1,107 @@
.login-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-main);
padding: 24px;
}
.login-card {
width: 100%;
max-width: 440px;
padding: 48px;
border-radius: 24px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.04);
@media (max-width: 480px) {
padding: 32px 24px;
}
}
.login-header {
text-align: center;
margin-bottom: 40px;
.logo-wrapper {
width: 80px;
height: 80px;
background: #fffbeb;
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
color: var(--brand-primary);
}
h1 {
font-size: 1.5rem;
letter-spacing: 0.1em;
margin-bottom: 8px;
}
p {
color: var(--text-muted);
font-size: 0.95rem;
font-weight: 500;
}
}
.login-form {
display: flex;
flex-direction: column;
gap: 24px;
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
label {
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 700;
text-transform: uppercase;
padding-left: 4px;
}
input {
width: 100%;
background: var(--bg-main);
border: 1px solid var(--border-color);
padding: 14px 16px;
border-radius: 12px;
font-size: 1rem;
transition: all 0.2s;
&:focus {
outline: none;
border-color: var(--brand-primary);
background: var(--bg-surface);
box-shadow: 0 0 0 4px rgba(180, 83, 9, 0.05);
}
}
}
.error-msg {
background: #fef2f2;
color: #ef4444;
padding: 12px;
border-radius: 10px;
font-size: 0.85rem;
font-weight: 600;
text-align: center;
border: 1px solid #fee2e2;
}
}
.login-footer {
margin-top: 40px;
text-align: center;
p {
font-size: 0.75rem;
color: var(--text-muted);
}
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Login } from './login';
describe('Login', () => {
let component: Login;
let fixture: ComponentFixture<Login>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Login]
})
.compileComponents();
fixture = TestBed.createComponent(Login);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,41 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../../services/auth';
import { Router } from '@angular/router';
import { LucideAngularModule, Shield } from 'lucide-angular';
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './login.html',
styleUrl: './login.scss'
})
export class LoginComponent {
readonly Shield = Shield;
private authService = inject(AuthService);
private router = inject(Router);
username = 'admin@fritosfresh.com';
password = 'secret';
loading = signal(false);
error = signal<string | null>(null);
onSubmit(event: Event) {
event.preventDefault();
this.loading.set(true);
this.error.set(null);
this.authService.login({ username: this.username, password: this.password }).subscribe({
next: () => {
this.router.navigate(['/dashboard']);
},
error: (_err: any) => {
console.log(_err);
this.loading.set(false);
this.error.set('Credenciales incorrectas. Intente nuevamente.');
}
});
}
}

View File

@ -0,0 +1,211 @@
<div class="page-header sticky-header">
<div class="header-left">
<button class="back-btn" routerLink="/non-conformities" type="button">
<lucide-icon [img]="ArrowLeft" size="20"></lucide-icon>
</button>
<div class="title-meta">
<h1>Editar No Conformidad</h1>
<span class="badge">ID: #{{ ncId }}</span>
</div>
</div>
<div class="header-actions">
<button type="button" class="cancel-btn" routerLink="/non-conformities">Cancelar</button>
<button type="submit" form="ncForm" class="gold-button" [disabled]="loading()">
<span *ngIf="!loading()">Guardar Cambios</span>
<span *ngIf="loading()">Procesando...</span>
</button>
</div>
</div>
<div class="form-container">
<div class="premium-card loading-card" *ngIf="loading() && !ncForm.get('description')?.value">
<lucide-icon [img]="RefreshCw" class="animate-spin" size="32"></lucide-icon>
<p>Cargando detalles...</p>
</div>
<form [formGroup]="ncForm" (ngSubmit)="onSubmit()" id="ncForm" class="nc-edit-form"
*ngIf="!loading() || ncForm.get('description')?.value">
<!-- Status & Level -->
<div class="form-row split">
<div class="premium-card field-card">
<label>Estado actual</label>
<select formControlName="status" class="status-select" [class]="ncForm.get('status')?.value">
<option value="open">Abierto</option>
<option value="in-progress">En Proceso</option>
<option value="re-inspect">Re-Inspección</option>
<option value="resolved">Resuelto</option>
<option value="closed">Cerrado</option>
</select>
</div>
<div class="premium-card field-card">
<label>Nivel de Gravedad</label>
<div class="level-selector">
<button type="button" [class.active]="ncForm.get('level')?.value === 'minor'"
(click)="ncForm.patchValue({level:'minor'})" class="level-btn minor">Leve</button>
<button type="button" [class.active]="ncForm.get('level')?.value === 'major'"
(click)="ncForm.patchValue({level:'major'})" class="level-btn major">Grave</button>
<button type="button" [class.active]="ncForm.get('level')?.value === 'critical'"
(click)="ncForm.patchValue({level:'critical'})" class="level-btn critical">Crítica</button>
</div>
</div>
</div>
<!-- Main Info -->
<div class="premium-card info-card">
<div class="form-group full-width">
<label>Descripción del Hallazgo</label>
<textarea formControlName="description" rows="4"></textarea>
</div>
<div class="form-grid">
<div class="form-group">
<label>Tipo de No Conformidad</label>
<select formControlName="nc_type">
<option [value]="null">Seleccione un tipo</option>
<option *ngFor="let t of ncTypes" [value]="t">{{ t }}</option>
</select>
</div>
<div class="form-group">
<label>Responsable</label>
<div class="input-with-icon">
<lucide-icon [img]="User" size="16"></lucide-icon>
<input type="text" formControlName="responsible_person" placeholder="Nombre del responsable">
</div>
</div>
<div class="form-group">
<label>Fecha Límite</label>
<div class="input-with-icon">
<lucide-icon [img]="Calendar" size="16"></lucide-icon>
<input type="datetime-local" formControlName="due_date">
</div>
</div>
</div>
</div>
<!-- Impact & Closure -->
<div class="form-row split">
<div class="premium-card field-card">
<label>Impacto Detectado</label>
<textarea formControlName="impact_description" rows="3" placeholder="Describa el impacto..."></textarea>
</div>
<div class="premium-card field-card">
<label>Detalles de Cierre (Opcional)</label>
<textarea formControlName="closure_description" rows="3"
placeholder="Acciones finales tomadas..."></textarea>
</div>
</div>
<!-- Action Checklist -->
<div class="premium-card checklist-card">
<div class="section-header">
<h3>Lista de Acciones Correctivas</h3>
<p>Pasos necesarios para solventar el hallazgo.</p>
</div>
<div class="add-action">
<input type="text" [(ngModel)]="newActionText" [ngModelOptions]="{standalone: true}"
(keyup.enter)="addAction()" placeholder="Nueva acción...">
<button class="add-action-btn" type="button" (click)="addAction()">
<lucide-icon [img]="Plus" size="20"></lucide-icon>
</button>
</div>
<div class="checklist-items">
<div class="checklist-item" *ngFor="let item of checklist(); let i = index">
<div class="check-toggle" (click)="toggleAction(i)" [class.completed]="item.completed">
<lucide-icon [img]="CheckCircle" size="20" *ngIf="item.completed"></lucide-icon>
<lucide-icon [img]="Clock" size="20" *ngIf="!item.completed"></lucide-icon>
</div>
<span class="action-text" [class.completed]="item.completed">{{ item.text }}</span>
<button type="button" class="remove-btn" (click)="removeAction(i)">
<lucide-icon [img]="X" size="16"></lucide-icon>
</button>
</div>
<div class="empty-state" *ngIf="checklist().length === 0">
No hay acciones programadas.
</div>
</div>
</div>
<!-- Evidence Management -->
<div class="premium-card evidence-card">
<div class="section-header">
<h3>Evidencias Fotográficas</h3>
<p>Registro visual del hallazgo y su resolución.</p>
</div>
<div class="existing-grid" *ngIf="existingEvidences().length > 0">
<div class="evidence-thumb" *ngFor="let ev of existingEvidences()">
<div class="thumb-media" (click)="openPreview(ev)">
<img [src]="'http://192.168.1.74:8000/' + ev.file_path">
<div class="overlay">
<lucide-icon [img]="Maximize2" size="20"></lucide-icon>
</div>
</div>
<div class="thumb-actions">
<textarea [(ngModel)]="ev.description" [ngModelOptions]="{standalone: true}"
(blur)="updateEvidenceDescription(ev)" placeholder="Nota..."></textarea>
<button type="button" class="del-btn" (click)="deleteExistingEvidence(ev.id)">
<lucide-icon [img]="Trash2" size="14"></lucide-icon>
</button>
</div>
</div>
</div>
<div class="upload-area">
<div class="upload-btn" (click)="fileInput.click()">
<lucide-icon [img]="FileText" size="24"></lucide-icon>
<span>Subir archivos</span>
</div>
<input type="file" #fileInput hidden (change)="onFileSelected($event)" multiple accept="image/*">
<button type="button" class="camera-btn" (click)="startCamera()">
<lucide-icon [img]="Camera" size="24"></lucide-icon>
<span>Cámara</span>
</button>
</div>
<div class="selected-previews" *ngIf="selectedFiles().length > 0">
<div class="preview-item" *ngFor="let item of selectedFiles(); let i = index">
<img [src]="item.previewUrl">
<textarea [(ngModel)]="item.description" [ngModelOptions]="{standalone: true}"
placeholder="Descripción..."></textarea>
<button type="button" class="close-p" (click)="removeSelectedFile(i)">
<lucide-icon [img]="X" size="14"></lucide-icon>
</button>
</div>
</div>
</div>
</form>
</div>
<!-- Preview Modal -->
<div class="preview-modal-overlay" *ngIf="previewModal()" (click)="closePreview()">
<div class="modal-content" (click)="$event.stopPropagation()">
<button class="close-modal" (click)="closePreview()">
<lucide-icon [img]="X" size="24"></lucide-icon>
</button>
<div class="modal-media">
<img [src]="previewModal()?.url">
</div>
<div class="modal-footer">
<p>{{ previewModal()?.description || 'Sin descripción' }}</p>
<a [href]="previewModal()?.url" target="_blank" class="download-link">
<lucide-icon [img]="ExternalLink" size="14"></lucide-icon>
Ver original
</a>
</div>
</div>
</div>
<!-- Camera Overlay -->
<div class="camera-overlay" *ngIf="showCamera()">
<div class="camera-container">
<video id="ncCameraPreview" autoplay playsinline muted></video>
<div class="camera-actions">
<button type="button" class="action-btn photo" (click)="takePhoto()">Tomar Foto</button>
<button type="button" class="action-btn cancel" (click)="stopCamera()">Cerrar</button>
</div>
</div>
</div>

View File

@ -0,0 +1,525 @@
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
position: sticky;
top: -24px;
background: rgba(248, 250, 252, 0.8);
backdrop-filter: blur(12px);
z-index: 1000;
padding: 20px 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.sticky-header {
margin-left: -24px;
margin-right: -24px;
padding-left: 24px;
padding-right: 24px;
border-bottom: 1px solid var(--border-color);
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
.back-btn {
background: white;
border: 1px solid var(--border-color);
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
color: var(--text-muted);
box-shadow: var(--shadow-sm);
transition: all 0.2s;
&:hover {
border-color: var(--brand-primary);
color: var(--brand-primary);
transform: translateX(-2px);
box-shadow: 0 4px 12px rgba(180, 83, 9, 0.1);
}
}
.title-meta {
h1 {
font-size: 1.5rem;
margin: 0;
color: var(--brand-secondary);
background: linear-gradient(135deg, var(--brand-secondary) 0%, #334155 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.badge {
font-size: 0.75rem;
font-weight: 700;
color: var(--brand-primary);
background: rgba(180, 83, 9, 0.1);
padding: 2px 8px;
border-radius: 6px;
letter-spacing: 0.05em;
}
}
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
.cancel-btn {
background: white;
color: var(--text-muted);
font-weight: 700;
padding: 12px 20px;
font-size: 0.85rem;
text-transform: uppercase;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
&:hover {
color: var(--brand-secondary);
border-color: var(--text-muted);
}
}
.gold-button {
height: 46px;
padding: 0 24px;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--brand-primary) 0%, #92400e 100%);
}
}
}
.form-container {
max-width: 1000px;
margin: 0 auto;
padding-bottom: 80px;
}
.nc-edit-form {
display: flex;
flex-direction: column;
gap: 32px;
}
.premium-card {
background: white;
padding: 32px;
border-radius: 24px;
border: 1px solid var(--border-color);
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.02);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 20px 40px -10px rgba(0, 0, 0, 0.05);
border-color: var(--border-hover);
}
.section-header {
margin-bottom: 24px;
h3 {
font-size: 1.25rem;
margin: 0;
display: flex;
align-items: center;
gap: 10px;
}
p {
margin: 6px 0 0;
font-size: 0.95rem;
color: var(--text-muted);
}
}
label {
display: block;
font-size: 0.75rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
margin-bottom: 10px;
}
}
.form-row.split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
@media (max-width: 850px) {
grid-template-columns: 1fr;
}
}
.input-with-icon {
position: relative;
lucide-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
transition: color 0.2s;
}
input,
select {
padding-left: 42px !important;
}
&:focus-within lucide-icon {
color: var(--brand-primary);
}
}
input,
select,
textarea {
width: 100%;
background: var(--bg-main);
border: 2px solid transparent;
padding: 14px 16px;
border-radius: 14px;
font-size: 0.95rem;
font-weight: 500;
color: var(--text-main);
transition: all 0.2s ease;
&::placeholder {
color: #94a3b8;
}
&:focus {
outline: none;
background: white;
border-color: var(--brand-primary);
box-shadow: 0 0 0 4px rgba(180, 83, 9, 0.1);
}
}
.status-select {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
&.open {
color: #ef4444;
background: rgba(239, 68, 68, 0.05);
}
&.in-progress {
color: #3b82f6;
background: rgba(59, 130, 246, 0.05);
}
&.re-inspect {
color: #f59e0b;
background: rgba(245, 158, 11, 0.05);
}
&.resolved {
color: #10b981;
background: rgba(16, 185, 129, 0.05);
}
&.closed {
color: #64748b;
background: rgba(100, 116, 139, 0.05);
}
}
.level-selector {
display: flex;
gap: 12px;
.level-btn {
flex: 1;
padding: 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 700;
border: 2px solid var(--border-color);
background: transparent;
color: var(--text-muted);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
border-color: var(--text-muted);
}
&.active {
color: white;
transform: translateY(-2px);
&.minor {
background: #10b981;
border-color: #10b981;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
&.major {
background: #f59e0b;
border-color: #f59e0b;
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.3);
}
&.critical {
background: #ef4444;
border-color: #ef4444;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
}
}
}
.checklist-items {
padding-top: 16px;
.empty-state {
padding: 16px;
border-radius: 16px;
border: 2px dashed var(--border-color);
}
.checklist-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: var(--bg-main);
border-radius: 16px;
margin-bottom: 12px;
transition: transform 0.2s ease;
&:hover {
transform: translateX(4px);
background: #f1f5f9;
}
.check-toggle {
cursor: pointer;
width: 24px;
height: 24px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
transition: all 0.2s;
background: white;
border: 2px solid var(--border-color);
&.completed {
background: #10b981;
border-color: #10b981;
color: white;
}
}
.action-text {
flex: 1;
font-size: 0.95rem;
font-weight: 500;
color: var(--text-main);
&.completed {
text-decoration: line-through;
color: var(--text-muted);
}
}
.remove-btn {
color: #ef4444;
opacity: 0;
transition: opacity 0.2s;
padding: 4px;
border-radius: 8px;
&:hover {
background: rgba(239, 68, 68, 0.1);
}
}
&:hover .remove-btn {
opacity: 0.6;
}
}
}
.evidence-card {
.existing-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 20px;
margin-bottom: 32px;
.evidence-thumb {
border-radius: 16px;
overflow: hidden;
background: white;
border: 1px solid var(--border-color);
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-md);
}
.thumb-media {
height: 140px;
position: relative;
cursor: pointer;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.overlay {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.4);
display: flex;
align-items: center;
justify-content: center;
color: white;
opacity: 0;
transition: opacity 0.2s;
backdrop-filter: blur(2px);
}
&:hover .overlay {
opacity: 1;
}
}
.thumb-actions {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
textarea {
font-size: 0.8rem;
padding: 8px;
border-radius: 10px;
border: 1px solid var(--border-color);
background: var(--bg-main);
}
.del-btn {
color: #ef4444;
font-size: 0.75rem;
font-weight: 700;
align-self: flex-end;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 6px;
&:hover {
background: rgba(239, 68, 68, 0.05);
}
}
}
}
}
.upload-area {
display: flex;
gap: 16px;
.upload-btn,
.camera-btn {
flex: 1;
height: 80px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
background: var(--bg-main);
border: 2px dashed var(--border-color);
border-radius: 20px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s;
span {
font-size: 0.85rem;
font-weight: 700;
}
&:hover {
color: var(--brand-primary);
border-color: var(--brand-primary);
background: white;
transform: scale(1.02);
}
}
}
}
.preview-modal-overlay {
backdrop-filter: blur(16px);
.modal-content {
border-radius: 32px;
box-shadow: 0 40px 100px -20px rgba(0, 0, 0, 0.5);
}
}
.camera-overlay {
.camera-actions {
.photo {
width: 80px;
height: 80px;
border-radius: 50%;
padding: 0;
border: 6px solid rgba(255, 255, 255, 0.3);
background: white;
&:active {
transform: scale(0.9);
}
}
}
}
.add-action {
display: flex;
gap: 8px;
.add-action-btn {
background: white;
color: var(--text-muted);
font-weight: 700;
padding: 0 20px;
font-size: 0.85rem;
text-transform: uppercase;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
&:hover {
color: var(--brand-secondary);
border-color: var(--text-muted);
}
}
}

View File

@ -0,0 +1,254 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, ActivatedRoute, Router } from '@angular/router';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { NonConformityService } from '../../services/non-conformity';
import { ActivityService } from '../../services/activity';
import { forkJoin } from 'rxjs';
import {
LucideAngularModule, ArrowLeft, TriangleAlert, CircleCheck, Clock, ListPlus, X,
Save, Camera, Trash2, Calendar, User, FileText, Maximize2, ExternalLink, RefreshCw
} from 'lucide-angular';
@Component({
selector: 'app-non-conformity-form',
standalone: true,
imports: [CommonModule, RouterModule, FormsModule, ReactiveFormsModule, LucideAngularModule],
templateUrl: './non-conformity-form.html',
styleUrl: './non-conformity-form.scss'
})
export class NonConformityFormComponent implements OnInit {
// Icons
readonly ArrowLeft = ArrowLeft;
readonly AlertTriangle = TriangleAlert;
readonly CheckCircle = CircleCheck;
readonly Clock = Clock;
readonly Plus = ListPlus;
readonly X = X;
readonly Save = Save;
readonly Camera = Camera;
readonly Trash2 = Trash2;
readonly Calendar = Calendar;
readonly User = User;
readonly FileText = FileText;
readonly Maximize2 = Maximize2;
readonly ExternalLink = ExternalLink;
readonly RefreshCw = RefreshCw;
private fb = inject(FormBuilder);
private ncService = inject(NonConformityService);
private activityService = inject(ActivityService);
private route = inject(ActivatedRoute);
private router = inject(Router);
ncId: number | null = null;
loading = signal(false);
ncForm: FormGroup;
// Dynamic collections
checklist = signal<any[]>([]);
newActionText = '';
existingEvidences = signal<any[]>([]);
selectedFiles = signal<any[]>([]); // { file, previewUrl, description, capturedAt }
// Meta options
ncTypes = [
'Errores humanos',
'Fallas en los procesos',
'Problemas de diseño',
'Cambios no controlados',
'Falta de comunicación'
];
// Camera Support
showCamera = signal(false);
private videoStream: MediaStream | null = null;
// Preview Modal
previewModal = signal<{ type: string, url: string, description: string } | null>(null);
constructor() {
this.ncForm = this.fb.group({
level: ['minor', Validators.required],
description: ['', Validators.required],
status: ['open', Validators.required],
due_date: [null],
responsible_person: [''],
nc_type: [null],
impact_description: [''],
closure_description: ['']
});
}
ngOnInit() {
this.ncId = Number(this.route.snapshot.paramMap.get('id'));
if (this.ncId) {
this.loadNC(this.ncId);
}
}
loadNC(id: number) {
this.loading.set(true);
this.ncService.getNC(id).subscribe({
next: (nc) => {
this.ncForm.patchValue({
level: nc.level,
description: nc.description,
status: nc.status,
due_date: nc.due_date ? new Date(nc.due_date).toISOString().slice(0, 16) : null,
responsible_person: nc.responsible_person,
nc_type: nc.nc_type,
impact_description: nc.impact_description,
closure_description: nc.closure_description
});
this.checklist.set(nc.action_checklist || []);
this.existingEvidences.set(nc.evidences || []);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
// Checklist Helpers
addAction() {
if (!this.newActionText.trim()) return;
this.checklist.update(list => [...list, { text: this.newActionText.trim(), completed: false }]);
this.newActionText = '';
}
removeAction(index: number) {
this.checklist.update(list => list.filter((_, i) => i !== index));
}
toggleAction(index: number) {
this.checklist.update(list => {
const newList = [...list];
newList[index].completed = !newList[index].completed;
return newList;
});
}
// Evidence Helpers
onFileSelected(event: any) {
const files: FileList = event.target.files;
for (let i = 0; i < files.length; i++) {
this.addFile(files[i]);
}
}
addFile(file: File) {
const reader = new FileReader();
reader.onload = (e: any) => {
this.selectedFiles.update(prev => [...prev, {
file: file,
previewUrl: e.target.result,
description: '',
capturedAt: new Date().toISOString()
}]);
};
reader.readAsDataURL(file);
}
removeSelectedFile(index: number) {
this.selectedFiles.update(prev => prev.filter((_, i) => i !== index));
}
deleteExistingEvidence(evId: number) {
if (confirm('¿Eliminar esta evidencia?')) {
this.activityService.deleteEvidence(evId).subscribe(() => {
this.existingEvidences.update(list => list.filter(e => e.id !== evId));
});
}
}
updateEvidenceDescription(ev: any) {
this.activityService.updateEvidence(ev.id, { description: ev.description }).subscribe();
}
// Camera Support
async startCamera() {
try {
this.videoStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
this.showCamera.set(true);
setTimeout(() => {
const video = document.getElementById('ncCameraPreview') as HTMLVideoElement;
if (video) video.srcObject = this.videoStream;
}, 100);
} catch (err) {
alert('No se pudo acceder a la cámara');
}
}
stopCamera() {
if (this.videoStream) {
this.videoStream.getTracks().forEach(t => t.stop());
this.videoStream = null;
}
this.showCamera.set(false);
}
takePhoto() {
const video = document.getElementById('ncCameraPreview') as HTMLVideoElement;
if (!video) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d')?.drawImage(video, 0, 0);
canvas.toBlob(blob => {
if (blob) {
const file = new File([blob], `nc_photo_${Date.now()}.jpg`, { type: 'image/jpeg' });
this.addFile(file);
this.stopCamera();
}
}, 'image/jpeg');
}
// Preview Modal
openPreview(ev: any) {
const baseUrl = 'http://192.168.1.74:8000/';
this.previewModal.set({
type: ev.media_type || 'image/jpeg',
url: ev.file_path.startsWith('http') ? ev.file_path : baseUrl + ev.file_path,
description: ev.description || ''
});
}
closePreview() {
this.previewModal.set(null);
}
onSubmit() {
if (this.ncForm.invalid || !this.ncId) return;
this.loading.set(true);
const data = {
...this.ncForm.value,
action_checklist: this.checklist()
};
this.ncService.updateNC(this.ncId, data).subscribe({
next: () => {
if (this.selectedFiles().length > 0) {
const uploads = this.selectedFiles().map(f =>
this.ncService.uploadEvidence(this.ncId!, f.file, f.description, f.capturedAt)
);
forkJoin(uploads).subscribe({
next: () => this.afterSave(),
error: () => this.afterSave()
});
} else {
this.afterSave();
}
},
error: () => this.loading.set(false)
});
}
afterSave() {
this.loading.set(false);
this.router.navigate(['/non-conformities']);
}
}

View File

@ -0,0 +1,90 @@
<div class="nc-container">
<div class="page-header">
<div class="title-area">
<h1>Seguimiento de No Conformidades</h1>
<p>Monitoreo, cierre y gestión avanzada de hallazgos.</p>
</div>
</div>
<div class="kanban-board" cdkDropListGroup>
<!-- Column Template -->
<ng-container *ngFor="let col of [
{ id: 'open', title: 'Abierto / Pendiente', icon: AlertTriangle, data: open(), class: 'open' },
{ id: 'in-progress', title: 'En Subsanación', icon: Play, data: inProgress(), class: 'in-progress' },
{ id: 'in-checking', title: 'En verificación', icon: Play, data: inChecking(), class: 'in-checking' },
{ id: 'closed', title: 'Cerrado / Conforme', icon: CheckCircle, data: closed(), class: 'done' }
]">
<div class="kanban-column">
<div class="column-header">
<div class="title-icon" [class]="col.class">
<lucide-icon [img]="col.icon" size="18"></lucide-icon>
<h2>{{ col.title }}</h2>
</div>
<span class="count">{{ col.data.length }}</span>
</div>
<div class="column-body" cdkDropList [cdkDropListData]="col.data"
(cdkDropListDropped)="drop($event, col.id)" [id]="col.id + '-list'">
<div class="kanban-card" [class.overdue]="isOverdue(nc)" *ngFor="let nc of col.data" cdkDrag
[cdkDragData]="nc" [cdkDragDisabled]="isMobile()">
<div class="card-header">
<span class="level-badge" [class]="nc.level">
{{ getLevelLabel(nc.level) }}
</span>
<div class="card-actions">
<a [routerLink]="['/non-conformities/edit', nc.id]" class="action-btn edit"
title="Editar Detalle">
<lucide-icon [img]="Edit" size="14"></lucide-icon>
</a>
<a [routerLink]="['/activities/edit', nc.activity_id]" class="action-btn"
title="Ver Actividad">
<lucide-icon [img]="ExternalLink" size="14"></lucide-icon>
</a>
</div>
</div>
<div class="card-body">
<p class="nc-desc">{{ nc.description }}</p>
<div class="nc-meta">
<div class="meta-item" *ngIf="nc.responsible_person">
<lucide-icon [img]="User" size="12"></lucide-icon>
<span>{{ nc.responsible_person }}</span>
</div>
<div class="meta-item" *ngIf="nc.due_date">
<lucide-icon [img]="Calendar" size="12"></lucide-icon>
<span [class.text-danger]="isOverdue(nc)">{{ nc.due_date | date:'shortDate'
}}</span>
</div>
<div class="meta-item" *ngIf="nc.nc_type">
<lucide-icon [img]="Type" size="12"></lucide-icon>
<span>{{ nc.nc_type }}</span>
</div>
</div>
<div class="progress-mini" *ngIf="nc.action_checklist?.length">
<div class="progress-bar">
<div class="fill" [style.width.%]="getChecklistProgress(nc)"></div>
</div>
<span>{{ getChecklistProgress(nc) }}%</span>
</div>
</div>
<div class="card-footer">
<span class="nc-id">NC #{{ nc.id }}</span>
<div class="evidences-count" *ngIf="nc.evidences?.length">
<lucide-icon [img]="Camera" size="12"></lucide-icon>
<span>{{ nc.evidences.length }}</span>
</div>
</div>
</div>
<div class="empty-column-msg" *ngIf="col.data.length === 0">
<p>Sin hallazgos en esta etapa</p>
</div>
</div>
</div>
</ng-container>
</div>
</div>

View File

@ -0,0 +1,543 @@
@import '../activity-list/activity-list.scss';
.nc-container {
@extend .activity-container;
}
.level-badge {
@extend .specialty-badge;
&.critical {
background: #fee2e2;
color: #991b1b;
}
&.major {
background: #fff7ed;
color: #9a3412;
}
&.minor {
background: #eff6ff;
color: #1e40af;
}
&.closed {
background: #f0fdf4;
color: #166534;
}
}
// Kanban Enhancements
.kanban-card {
&.overdue {
border: 2px solid #ef4444;
box-shadow: 0 0 10px rgba(239, 68, 68, 0.2);
.card-header::after {
content: 'VENCIDA';
font-size: 0.65rem;
font-weight: 800;
color: #ef4444;
background: #fef2f2;
padding: 2px 8px;
border-radius: 4px;
}
}
.nc-desc {
font-size: 0.9rem;
color: var(--text-main);
margin-bottom: 12px;
line-height: 1.5;
}
.nc-meta {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
.meta-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: var(--text-muted);
lucide-icon {
color: var(--brand-secondary);
}
.text-danger {
color: #ef4444;
font-weight: 700;
}
}
}
.progress-mini {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
.progress-bar {
flex: 1;
height: 4px;
background: var(--bg-main);
border-radius: 10px;
overflow: hidden;
.fill {
height: 100%;
background: var(--brand-primary);
transition: width 0.3s ease;
}
}
span {
font-size: 0.7rem;
font-weight: 700;
color: var(--text-muted);
min-width: 30px;
}
}
.card-actions {
display: flex;
gap: 8px;
.action-btn {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-main);
color: var(--text-muted);
transition: all 0.2s;
&:hover {
color: var(--brand-primary);
background: #fdf2f2;
}
&.edit:hover {
background: #f0f9ff;
color: #0ea5e9;
}
}
}
}
// Modal Styles
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.modal-content {
width: 100%;
max-width: 800px;
max-height: 90vh;
background: white;
border-radius: 24px;
display: flex;
flex-direction: column;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.modal-header {
padding: 24px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
.modal-title {
display: flex;
align-items: center;
gap: 12px;
color: var(--brand-secondary);
h2 {
font-size: 1.25rem;
margin: 0;
}
}
.close-btn {
background: var(--bg-main);
border: none;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
cursor: pointer;
&:hover {
background: #fef2f2;
color: #ef4444;
}
}
}
.modal-body {
padding: 24px;
overflow-y: auto;
flex: 1;
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
.full-width {
grid-column: span 2;
@media (max-width: 640px) {
grid-column: span 1;
}
}
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
label {
font-size: 0.75rem;
font-weight: 800;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
input,
select,
textarea {
background: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 12px;
font-size: 0.95rem;
color: var(--text-main);
&:focus {
border-color: var(--brand-primary);
outline: none;
background: white;
}
}
textarea {
min-height: 80px;
resize: vertical;
}
.input-with-icon {
position: relative;
lucide-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
}
input {
padding-left: 40px;
width: 100%;
border-radius: 12px;
}
}
}
.form-divider {
border: none;
height: 1px;
background: var(--border-color);
margin: 20px 0;
}
.form-section {
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
color: var(--brand-secondary);
h3 {
font-size: 0.95rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
}
}
}
// Checklist UI
.checklist-container {
display: flex;
flex-direction: column;
gap: 10px;
.checklist-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--bg-main);
border-radius: 10px;
transition: all 0.2s;
input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
span {
flex: 1;
font-size: 0.9rem;
&.completed {
text-decoration: line-through;
color: var(--text-muted);
}
}
.remove-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
&:hover {
color: #ef4444;
}
}
}
.checklist-add {
display: flex;
gap: 10px;
margin-top: 8px;
input {
flex: 1;
background: white;
border: 1.5px dashed var(--border-color);
}
button {
background: var(--brand-primary);
color: white;
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
}
}
// Evidences UI in Modal
.evidence-strip {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 12px;
margin-bottom: 16px;
.evidence-thumb {
width: 80px;
height: 80px;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--border-color);
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
.upload-controls {
display: flex;
gap: 12px;
margin-bottom: 16px;
button {
flex: 1;
padding: 10px;
border: 1px solid var(--border-color);
background: white;
border-radius: 10px;
font-size: 0.85rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--brand-primary);
color: var(--brand-primary);
background: var(--bg-main);
}
}
}
.camera-mini-overlay {
background: black;
border-radius: 16px;
overflow: hidden;
position: relative;
margin-bottom: 16px;
video {
width: 100%;
display: block;
}
.camera-mini-actions {
position: absolute;
bottom: 16px;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 12px;
button {
padding: 8px 16px;
border-radius: 50px;
font-weight: 700;
font-size: 0.75rem;
text-transform: uppercase;
&.snap-btn {
background: white;
color: black;
}
&.cancel-btn {
background: rgba(0, 0, 0, 0.5);
color: white;
border: 1px solid white;
}
}
}
}
.temp-previews {
display: flex;
gap: 10px;
flex-wrap: wrap;
.temp-item {
position: relative;
width: 60px;
height: 60px;
border-radius: 8px;
overflow: hidden;
border: 2px solid var(--brand-primary);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.remove-temp {
position: absolute;
top: 2px;
right: 2px;
background: #ef4444;
color: white;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
.modal-footer {
padding: 24px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 16px;
button {
padding: 12px 24px;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
}
.btn-cancel {
background: transparent;
color: var(--text-muted);
}
.btn-save {
border: none;
display: flex;
align-items: center;
gap: 8px;
}
}
.custom-scrollbar {
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 10px;
}
&:hover::-webkit-scrollbar-thumb {
background: var(--text-muted);
}
}

View File

@ -0,0 +1,119 @@
import { Component, inject, signal, OnInit, computed, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { NonConformityService } from '../../services/non-conformity';
import {
LucideAngularModule, TriangleAlert, CircleCheck, Clock, Play, X,
ExternalLink, SquarePen, Trash2, Calendar, User, SquareCheck, Type, Activity, FileText, Camera, Plus, Save
} from 'lucide-angular';
import {
DragDropModule,
CdkDragDrop
} from '@angular/cdk/drag-drop';
@Component({
selector: 'app-non-conformity-list',
standalone: true,
imports: [CommonModule, RouterModule, FormsModule, ReactiveFormsModule, LucideAngularModule, DragDropModule],
templateUrl: './non-conformity-list.html',
styleUrl: './non-conformity-list.scss'
})
export class NonConformityListComponent implements OnInit {
readonly AlertTriangle = TriangleAlert;
readonly CheckCircle = CircleCheck;
readonly Clock = Clock;
readonly Play = Play;
readonly X = X;
readonly ExternalLink = ExternalLink;
readonly Edit = SquarePen;
readonly Trash2 = Trash2;
readonly Calendar = Calendar;
readonly User = User;
readonly CheckSquare = SquareCheck;
readonly Type = Type;
readonly Activity = Activity;
readonly FileText = FileText;
readonly Camera = Camera;
readonly Plus = Plus;
readonly Save = Save;
private ncService = inject(NonConformityService);
ncs = signal<any[]>([]);
loading = signal(false);
isMobile = signal(false);
// Kanban Columns
open = computed(() => this.ncs().filter(nc => nc.status === 'open'));
inProgress = computed(() => this.ncs().filter(nc => nc.status === 'in-progress' || nc.status === 're-inspect'));
closed = computed(() => this.ncs().filter(nc => nc.status === 'closed' || nc.status === 'resolved'));
inChecking = computed(() => this.ncs().filter(nc => nc.status === 'in-checking'));
@HostListener('window:resize')
onResize() {
this.checkMobile();
}
ngOnInit() {
this.checkMobile();
this.loadNCs();
}
private checkMobile() {
this.isMobile.set(window.innerWidth < 768);
}
loadNCs() {
this.loading.set(true);
this.ncService.getNCs().subscribe({
next: (data) => {
this.ncs.set(data);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
drop(event: CdkDragDrop<any[]>, newStatus: string) {
if (event.previousContainer !== event.container) {
const item = event.item.data;
const oldStatus = item.status;
const updatedNCs = this.ncs().map(nc =>
nc.id === item.id ? { ...nc, status: newStatus } : nc
);
this.ncs.set(updatedNCs);
this.ncService.updateNC(item.id, { status: newStatus }).subscribe({
error: () => {
const revertedNCs = this.ncs().map(nc =>
nc.id === item.id ? { ...nc, status: oldStatus } : nc
);
this.ncs.set(revertedNCs);
alert('Error al actualizar el estado de la No Conformidad.');
}
});
}
}
getLevelLabel(level: string): string {
const labels: any = {
critical: 'Crítica',
major: 'Grave',
minor: 'Leve'
};
return labels[level] || level;
}
isOverdue(nc: any): boolean {
if (!nc.due_date || nc.status === 'closed') return false;
return new Date(nc.due_date) < new Date();
}
getChecklistProgress(nc: any): number {
if (!nc.action_checklist || nc.action_checklist.length === 0) return 0;
const completed = nc.action_checklist.filter((a: any) => a.completed).length;
return Math.round((completed / nc.action_checklist.length) * 100);
}
}

View File

@ -0,0 +1,92 @@
<div class="form-container">
<div class="page-header sticky-header">
<div class="header-left">
<button class="back-btn" routerLink="/projects" type="button">
<lucide-icon [img]="ArrowLeft" size="20"></lucide-icon>
</button>
<h1>{{ isEditMode() ? 'Editar' : 'Nuevo' }} Proyecto</h1>
<div class="subproject-badge" *ngIf="parentProjectName()">
Subproyecto de: <strong>{{ parentProjectName() }}</strong>
</div>
</div>
<div class="header-actions">
<button type="button" class="cancel-btn" routerLink="/projects">Cancelar</button>
<button type="submit" form="projectForm" class="gold-button" [disabled]="loading() || projectForm.invalid">
<lucide-icon *ngIf="!loading()" [img]="Save" size="18"></lucide-icon>
<lucide-icon *ngIf="loading()" [img]="Loader2" size="18" class="animate-spin"></lucide-icon>
<span>{{ loading() ? 'Guardando...' : 'Guardar' }}</span>
</button>
</div>
</div>
<form [formGroup]="projectForm" (ngSubmit)="onSubmit()" id="projectForm" class="premium-card">
<div class="form-grid">
<div class="form-group full-width">
<label>Nombre del Proyecto</label>
<input type="text" formControlName="name" placeholder="Ej: Ampliación Planta ATE - Fase 2">
</div>
<div class="form-group">
<label>Código Único</label>
<input type="text" formControlName="code" placeholder="Ej: PJ-2024-001">
</div>
<div class="form-group">
<label>Estado</label>
<select formControlName="status">
<option value="active">Activo</option>
<option value="completed">Completado</option>
<option value="on_hold">En Pausa</option>
</select>
</div>
<div class="form-group full-width">
<label>Ubicación / Dirección</label>
<input type="text" formControlName="location" placeholder="Ej: Av. Las Industrias 123, Lima">
</div>
<div class="form-group">
<label>Fecha de Inicio</label>
<input type="date" formControlName="start_date">
</div>
<div class="form-group">
<label>Fecha de Finalización Est.</label>
<input type="date" formControlName="end_date">
</div>
</div>
<div class="selection-section">
<div class="section-column">
<label>Especialidades Involucradas</label>
<div class="pill-grid">
<button type="button" *ngFor="let s of specialties()" class="pill"
[class.selected]="isSelected('specialty_ids', s.id)"
(click)="toggleSelection('specialty_ids', s.id)">
{{ s.name }}
</button>
</div>
</div>
<div class="section-column">
<label>Contratistas Asignados</label>
<div class="pill-grid">
<button type="button" *ngFor="let c of contractors()" class="pill"
[class.selected]="isSelected('contractor_ids', c.id)"
(click)="toggleSelection('contractor_ids', c.id)">
{{ c.name }}
</button>
</div>
</div>
</div>
<div class="form-actions">
<button type="button" class="cancel-btn" routerLink="/projects">Cancelar</button>
<button type="submit" class="gold-button" [disabled]="loading() || projectForm.invalid">
<lucide-icon *ngIf="!loading()" [img]="Save" size="18"></lucide-icon>
<lucide-icon *ngIf="loading()" [img]="Loader2" size="18" class="animate-spin"></lucide-icon>
<span>{{ loading() ? 'Guardando...' : 'Guardar Proyecto' }}</span>
</button>
</div>
</form>
</div>

View File

@ -0,0 +1,247 @@
.form-container {
max-width: 900px;
margin: 0 auto;
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 32px;
position: sticky;
top: -24px;
background: var(--bg-main);
z-index: 100;
padding: 16px 0;
border-bottom: 1px solid transparent;
&.sticky-header {
background: var(--bg-main);
margin-left: -24px;
margin-right: -24px;
padding-left: 24px;
padding-right: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03);
border-bottom-color: var(--border-color);
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
.cancel-btn {
background: transparent;
color: var(--text-muted);
font-weight: 600;
padding: 10px 20px;
border: none;
cursor: pointer;
&:hover {
color: var(--brand-secondary);
}
}
.gold-button {
padding: 10px 24px;
min-width: 120px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
}
.back-btn {
background: var(--bg-surface);
border: 1px solid var(--border-color);
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-muted);
&:hover {
border-color: var(--brand-primary);
color: var(--brand-primary);
}
}
h1 {
font-size: 1.5rem;
}
.subproject-badge {
display: inline-flex;
align-items: center;
padding: 4px 12px;
background: rgba(180, 83, 9, 0.08);
color: var(--brand-primary);
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
border: 1px solid rgba(180, 83, 9, 0.2);
strong {
margin-left: 4px;
color: var(--brand-secondary);
}
}
}
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 32px;
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
.full-width {
grid-column: span 2;
@media (max-width: 640px) {
grid-column: span 1;
}
}
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
label {
font-size: 0.85rem;
color: var(--text-muted);
font-weight: 700;
text-transform: uppercase;
}
input,
select {
background: var(--bg-main);
border: 1px solid var(--border-color);
color: var(--text-main);
padding: 12px 14px;
border-radius: 10px;
font-size: 0.95rem;
transition: all 0.2s;
&:focus {
outline: none;
border-color: var(--brand-primary);
background: var(--bg-surface);
}
}
}
.selection-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
padding-top: 32px;
border-top: 1px solid var(--border-color);
margin-bottom: 40px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
gap: 32px;
}
.section-column {
display: flex;
flex-direction: column;
gap: 16px;
label {
font-size: 0.85rem;
color: var(--brand-secondary);
font-weight: 700;
text-transform: uppercase;
}
}
}
.pill-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.pill {
background: var(--bg-main);
border: 1px solid var(--border-color);
padding: 8px 16px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
transition: all 0.2s;
&:hover {
border-color: var(--border-hover);
color: var(--text-main);
}
&.selected {
background: rgba(180, 83, 9, 0.1);
border-color: var(--brand-primary);
color: var(--brand-primary);
}
}
.form-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 16px;
padding-top: 24px;
border-top: 1px solid var(--border-color);
.cancel-btn {
background: transparent;
color: var(--text-muted);
font-weight: 600;
padding: 10px 20px;
&:hover {
color: var(--brand-secondary);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 480px) {
flex-direction: column-reverse;
.gold-button,
.cancel-btn {
width: 100%;
}
}
}

View File

@ -0,0 +1,136 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { ProjectService } from '../../services/project';
import { SpecialtyService } from '../../services/specialty';
import { ContractorService } from '../../services/contractor';
import { LucideAngularModule, ArrowLeft, Save, Loader2 } from 'lucide-angular';
@Component({
selector: 'app-project-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, RouterModule, LucideAngularModule],
templateUrl: './project-form.html',
styleUrl: './project-form.scss'
})
export class ProjectFormComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Save = Save;
readonly Loader2 = Loader2;
private fb = inject(FormBuilder);
private projectService = inject(ProjectService);
private specialtyService = inject(SpecialtyService);
private contractorService = inject(ContractorService);
private router = inject(Router);
private route = inject(ActivatedRoute);
projectForm: FormGroup;
isEditMode = signal(false);
loading = signal(false);
projectId = signal<number | null>(null);
specialties = signal<any[]>([]);
contractors = signal<any[]>([]);
constructor() {
this.projectForm = this.fb.group({
name: ['', Validators.required],
code: ['', Validators.required],
location: [''],
start_date: [''],
end_date: [''],
status: ['active'],
parent_id: [null],
specialty_ids: [[]],
contractor_ids: [[]]
});
}
ngOnInit() {
this.loadDependencies();
const id = this.route.snapshot.params['id'];
if (id) {
this.isEditMode.set(true);
this.projectId.set(+id);
this.loadProject(+id);
}
const parentId = this.route.snapshot.queryParams['parentId'];
if (parentId) {
this.projectForm.patchValue({ parent_id: +parentId });
this.loadParentProject(+parentId);
}
}
parentProjectName = signal<string | null>(null);
loadParentProject(id: number) {
this.projectService.getProject(id).subscribe(p => {
this.parentProjectName.set(p.name);
});
}
loadDependencies() {
this.specialtyService.getSpecialties().subscribe(data => this.specialties.set(data));
this.contractorService.getContractors().subscribe(data => this.contractors.set(data));
}
loadProject(id: number) {
this.loading.set(true);
this.projectService.getProject(id).subscribe({
next: (project) => {
this.projectForm.patchValue({
...project,
start_date: project.start_date ? project.start_date.split('T')[0] : '',
end_date: project.end_date ? project.end_date.split('T')[0] : '',
specialty_ids: project.specialties?.map((s: any) => s.id) || [],
contractor_ids: project.contractors?.map((c: any) => c.id) || []
});
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
onSubmit() {
if (this.projectForm.invalid) return;
this.loading.set(true);
const data = this.projectForm.value;
// Ensure dates are sent correctly or null if empty
if (!data.start_date) delete data.start_date;
if (!data.end_date) delete data.end_date;
const action = this.isEditMode()
? this.projectService.updateProject(this.projectId()!, data)
: this.projectService.createProject(data);
action.subscribe({
next: () => {
this.router.navigate(['/projects']);
},
error: (err) => {
console.error('Error saving project:', err);
this.loading.set(false);
alert('Error al guardar el proyecto. Verifique el código único.');
}
});
}
toggleSelection(controlName: string, id: number) {
const control = this.projectForm.get(controlName);
const currentValues = control?.value as number[];
if (currentValues.includes(id)) {
control?.setValue(currentValues.filter(v => v !== id));
} else {
control?.setValue([...currentValues, id]);
}
}
isSelected(controlName: string, id: number): boolean {
return (this.projectForm.get(controlName)?.value as number[]).includes(id);
}
}

View File

@ -0,0 +1,91 @@
<div class="project-container">
<div class="page-header">
<div class="title-area">
<h1>Gestión de Proyectos</h1>
<p>Monitoreo y administración de frentes de obra activos.</p>
</div>
<button class="gold-button" routerLink="/projects/new">
<lucide-icon [img]="Plus" size="20"></lucide-icon>
<span>Nuevo Proyecto</span>
</button>
</div>
<div class="premium-card search-card">
<div class="search-box">
<lucide-icon [img]="Search" size="18"></lucide-icon>
<input type="text" placeholder="Buscar por nombre o código de proyecto...">
</div>
</div>
@if(loading()) {
<div class="loading-placeholder">Cargando proyectos...</div>
} @else {
<div class="project-grid">
@for(p of projects(); track p.id) {
<div class="premium-card project-card">
<div class="card-top">
<div class="project-info">
<span class="code">{{ p.code }}</span>
<h3>{{ p.name }}</h3>
</div>
<div class="status-badge" [class]="p.status">
{{ p.status | titlecase }}
</div>
</div>
<div class="card-details">
<div class="detail-item">
<lucide-icon [img]="MapPin" size="16"></lucide-icon>
<span>{{ p.location || 'Sin ubicación' }}</span>
</div>
<div class="detail-item">
<lucide-icon [img]="Calendar" size="16"></lucide-icon>
<span>{{ p.start_date | date:'shortDate' }} - {{ p.end_date | date:'shortDate' }}</span>
</div>
</div>
<div class="card-stats">
<div class="stat">
<lucide-icon [img]="HardHat" size="14"></lucide-icon>
<span>{{ p.specialties?.length || 0 }} Especialidades</span>
</div>
<div class="stat">
<lucide-icon [img]="Users" size="14"></lucide-icon>
<span>{{ p.contractors?.length || 0 }} Contratistas</span>
</div>
</div>
<div class="card-footer" style="align-items: center;">
<button class="action-btn subproject" (click)="createSubproject(p)" title="Generar Subproyecto">
<lucide-icon [img]="GitBranch" size="18"></lucide-icon>
</button>
<div class="footer-spacer stat">Sub proyectos:</div>
@for(sub of p.subprojects; track sub.id) {
<div class="code" style="margin-bottom: 0px;">{{ sub.code }}</div>
}
<div class="footer-spacer"></div>
<button class="action-btn edit" [routerLink]="['/projects/edit', p.id]" title="Editar">
<lucide-icon [img]="Edit2" size="18"></lucide-icon>
</button>
<button class="action-btn delete" (click)="deleteProject(p.id)" title="Eliminar">
<lucide-icon [img]="Trash2" size="18"></lucide-icon>
</button>
</div>
</div>
}
</div>
}
<div class="empty-state" *ngIf="!loading() && projects().length === 0">
<lucide-icon [img]="HardHat" size="48" class="empty-icon"></lucide-icon>
<p>No hay proyectos registrados aún.</p>
<button class="text-btn" routerLink="/projects/new">Comenzar primer proyecto</button>
</div>
</div>

View File

@ -0,0 +1,228 @@
.project-container {
max-width: 1200px;
margin: 0 auto;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
margin-bottom: 32px;
@media (max-width: 640px) {
flex-direction: column;
align-items: flex-start;
}
.title-area {
h1 {
font-size: 1.8rem;
margin-bottom: 4px;
}
p {
color: var(--text-muted);
font-size: 0.95rem;
}
}
.gold-button {
@media (max-width: 640px) {
width: 100%;
}
}
}
}
.search-card {
margin-bottom: 32px;
padding: 12px 20px;
}
.search-box {
display: flex;
align-items: center;
gap: 12px;
color: var(--text-muted);
input {
border: none;
background: transparent;
flex: 1;
font-size: 0.95rem;
color: var(--text-main);
&:focus {
outline: none;
}
}
}
.project-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 24px;
@media (max-width: 480px) {
grid-template-columns: 1fr;
}
}
.project-card {
display: flex;
flex-direction: column;
padding: 24px;
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.06);
}
.card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
.project-info {
h3 {
font-size: 1.2rem;
color: var(--brand-secondary);
}
}
.status-badge {
font-size: 0.7rem;
font-weight: 700;
padding: 4px 10px;
border-radius: 20px;
text-transform: uppercase;
&.active {
background: #ecfdf5;
color: #047857;
}
&.completed {
background: #eff6ff;
color: #1d4ed8;
}
&.on_hold {
background: #fff7ed;
color: #d97706;
}
}
}
.card-details {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
.detail-item {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-muted);
font-size: 0.9rem;
font-weight: 500;
}
}
.card-stats {
display: flex;
gap: 16px;
margin-bottom: 24px;
padding: 12px;
background: var(--bg-main);
border-radius: 12px;
}
.card-footer {
margin-top: auto;
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
.action-btn {
width: 38px;
height: 38px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
background: var(--bg-surface);
border: 1px solid var(--border-color);
&.subproject {
color: var(--brand-secondary);
opacity: 0.8;
&:hover {
border-color: var(--brand-secondary);
background: rgba(15, 23, 42, 0.04);
opacity: 1;
}
}
&.edit {
color: var(--brand-primary);
&:hover {
border-color: var(--brand-primary);
background: rgba(180, 83, 9, 0.04);
}
}
&.delete {
color: #ef4444;
&:hover {
border-color: #ef4444;
background: #fef2f2;
}
}
}
.footer-spacer {
flex: 1;
}
}
}
.loading-placeholder {
text-align: center;
padding: 60px;
color: var(--text-muted);
font-weight: 500;
}
.empty-state {
text-align: center;
padding: 80px 24px;
.empty-icon {
margin-bottom: 20px;
opacity: 0.2;
}
p {
font-size: 1.1rem;
color: var(--text-muted);
margin-bottom: 24px;
}
.text-btn {
background: none;
color: var(--brand-primary);
font-weight: 700;
}
}

View File

@ -0,0 +1,58 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { ProjectService } from '../../services/project';
import { LucideAngularModule, Plus, Search, MapPin, Calendar, Users, Edit2, Trash2, HardHat, GitBranch } from 'lucide-angular';
@Component({
selector: 'app-project-list',
standalone: true,
imports: [CommonModule, RouterModule, LucideAngularModule],
templateUrl: './project-list.html',
styleUrl: './project-list.scss'
})
export class ProjectListComponent {
readonly Plus = Plus;
readonly Search = Search;
readonly MapPin = MapPin;
readonly Calendar = Calendar;
readonly Users = Users;
readonly Edit2 = Edit2;
readonly Trash2 = Trash2;
readonly HardHat = HardHat;
readonly GitBranch = GitBranch;
private projectService = inject(ProjectService);
private router = inject(Router);
projects = signal<any[]>([]);
loading = signal(true);
constructor() {
this.loadProjects();
}
loadProjects() {
this.loading.set(true);
this.projectService.getProjects().subscribe({
next: (data) => {
this.projects.set(data);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
deleteProject(id: number) {
if (confirm('¿Está seguro de eliminar este proyecto?')) {
this.projectService.deleteProject(id).subscribe(() => {
this.loadProjects();
});
}
}
createSubproject(parent: any) {
this.router.navigate(['/projects/new'], {
queryParams: { parentId: parent.id }
});
}
}

View File

@ -0,0 +1,47 @@
<nav class="sidebar" [class.collapsed]="isCollapsed">
<div class="sidebar-header">
<div class="logo">
<img src="logo.png" alt="Logo" style="width: 32px; height: 32px;">
<div style="display: flex;flex-direction: column;transform: translateY(5px);">
<div style="font-size: 1.5rem;">
<span>Suma</span><span style="color: var(--brand-primary);font-weight: bold;">Q</span>
</div>
<span style="font-size: 0.8rem;font-weight:200;line-height:0.7rem;">smart</span>
</div>
</div>
</div>
<div class="menu-section">
<a routerLink="/dashboard" routerLinkActive="active" class="menu-item">
<lucide-icon [img]="LayoutDashboard" size="20"></lucide-icon>
<span>Dashboard</span>
</a>
<a routerLink="/projects" routerLinkActive="active" class="menu-item">
<lucide-icon [img]="HardHat" size="20"></lucide-icon>
<span>Proyectos</span>
</a>
<a routerLink="/activities" routerLinkActive="active" class="menu-item">
<lucide-icon [img]="ClipboardList" size="20"></lucide-icon>
<span *ngIf="!isCollapsed">Actividades</span>
</a>
<a routerLink="/contractors" routerLinkActive="active" class="menu-item">
<lucide-icon [img]="Users" size="20"></lucide-icon>
<span *ngIf="!isCollapsed">Contratistas</span>
</a>
<a routerLink="/non-conformities" routerLinkActive="active" class="menu-item">
<lucide-icon [img]="AlertTriangle" size="20"></lucide-icon>
<span *ngIf="!isCollapsed">No Conformidades</span>
</a>
<a routerLink="/reports" routerLinkActive="active" class="menu-item">
<lucide-icon [img]="FileText" size="20"></lucide-icon>
<span *ngIf="!isCollapsed">Informes</span>
</a>
</div>
<div class="sidebar-footer">
<button class="logout-btn" (click)="onLogout()">
<lucide-icon [img]="LogOut" size="20"></lucide-icon>
<span>Cerrar Sesión</span>
</button>
</div>
</nav>

View File

@ -0,0 +1,138 @@
.sidebar {
height: 100%;
background: var(--bg-surface);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 0;
width: var(--nav-width);
transition: width 0.3s ease;
overflow: hidden;
&.collapsed {
width: var(--nav-width-collapsed);
.sidebar-header .logo span,
.menu-item span,
.logout-btn span {
display: none;
}
.sidebar-header {
padding: 0;
justify-content: center;
}
.menu-section {
padding: 24px 8px;
align-items: center;
}
.menu-item {
padding: 12px;
justify-content: center;
}
.sidebar-footer {
padding: 16px 8px;
display: flex;
justify-content: center;
}
.logout-btn {
padding: 12px;
justify-content: center;
}
}
}
.sidebar-header {
height: var(--header-height);
display: flex;
align-items: center;
padding: 0 24px;
border-bottom: 1px solid var(--border-color);
transition: all 0.3s ease;
.logo {
display: flex;
align-items: center;
gap: 12px;
color: var(--brand-secondary);
font-weight: 800;
font-size: 1rem;
letter-spacing: -0.02em;
white-space: nowrap;
.logo-icon {
color: var(--brand-primary);
flex-shrink: 0;
}
}
}
.menu-section {
flex: 1;
padding: 24px 12px;
display: flex;
flex-direction: column;
gap: 4px;
transition: all 0.3s ease;
}
.menu-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 10px;
color: var(--text-muted);
font-weight: 500;
font-size: 0.95rem;
white-space: nowrap;
transition: all 0.3s ease;
&:hover {
background: var(--bg-main);
color: var(--brand-secondary);
}
&.active {
background: rgba(180, 83, 9, 0.08);
color: var(--brand-primary);
font-weight: 600;
lucide-icon {
color: var(--brand-primary);
}
}
lucide-icon {
flex-shrink: 0;
transition: color 0.2s;
}
}
.sidebar-footer {
padding: 16px;
border-top: 1px solid var(--border-color);
transition: all 0.3s ease;
}
.logout-btn {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 10px;
background: transparent;
color: #ef4444; // Red 500
font-size: 0.95rem;
white-space: nowrap;
transition: all 0.3s ease;
&:hover {
background: #fef2f2; // Lighter red bg
}
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Sidebar } from './sidebar';
describe('Sidebar', () => {
let component: Sidebar;
let fixture: ComponentFixture<Sidebar>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Sidebar]
})
.compileComponents();
fixture = TestBed.createComponent(Sidebar);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,35 @@
import { Component, inject, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { AuthService } from '../../services/auth';
import { LucideAngularModule, Shield, LayoutDashboard, HardHat, ClipboardList, FileText, LogOut, AlertTriangle, Users } from 'lucide-angular';
@Component({
selector: 'app-sidebar',
standalone: true,
imports: [
CommonModule,
RouterModule,
LucideAngularModule
],
templateUrl: './sidebar.html',
styleUrl: './sidebar.scss'
})
export class SidebarComponent {
@Input() isCollapsed = false;
readonly Shield = Shield;
readonly LayoutDashboard = LayoutDashboard;
readonly HardHat = HardHat;
readonly ClipboardList = ClipboardList;
readonly FileText = FileText;
readonly LogOut = LogOut;
readonly AlertTriangle = AlertTriangle;
readonly Users = Users;
private authService = inject(AuthService);
onLogout() {
this.authService.logout();
}
}

View File

@ -0,0 +1,15 @@
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { AuthService } from '../services/auth';
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isAuthenticated()) {
return true;
} else {
router.navigate(['/login']);
return false;
}
};

View File

@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { HttpInterceptorFn } from '@angular/common/http';
import { authInterceptor } from './auth-interceptor';
describe('authInterceptor', () => {
const interceptor: HttpInterceptorFn = (req, next) =>
TestBed.runInInjectionContext(() => authInterceptor(req, next));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(interceptor).toBeTruthy();
});
});

View File

@ -0,0 +1,16 @@
import { HttpInterceptorFn } from '@angular/common/http';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = localStorage.getItem('access_token');
if (token) {
const cloned = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
return next(cloned);
}
return next(req);
};

View File

@ -0,0 +1,52 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class ActivityService {
private http = inject(HttpClient);
private apiUrl = environment.apiUrl + '/activities';
getActivities(projectId?: number, specialtyId?: number) {
const params: any = {};
if (projectId) params.project_id = projectId;
if (specialtyId) params.specialty_id = specialtyId;
return this.http.get<any[]>(this.apiUrl, { params });
}
getActivity(id: number) {
return this.http.get<any>(`${this.apiUrl}/${id}`);
}
createActivity(activity: any) {
return this.http.post<any>(this.apiUrl, activity);
}
uploadEvidence(activityId: number, file: File, description?: string, capturedAt?: string) {
const formData = new FormData();
formData.append('file', file);
if (description) formData.append('description', description);
if (capturedAt) formData.append('captured_at', capturedAt);
return this.http.post<any>(`${this.apiUrl}/${activityId}/upload`, formData);
}
updateActivity(id: number, activity: any) {
return this.http.put<any>(`${this.apiUrl}/${id}`, activity);
}
retryTranscription(evidenceId: number) {
return this.http.post<any>(`${this.apiUrl}/evidence/${evidenceId}/retry-transcription`, {});
}
updateEvidence(evidenceId: number, data: any) {
return this.http.put<any>(`${this.apiUrl}/evidence/${evidenceId}`, data);
}
deleteEvidence(evidenceId: number) {
return this.http.delete<any>(`${this.apiUrl}/evidence/${evidenceId}`);
}
}

View File

@ -0,0 +1,54 @@
import { Injectable, signal, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { tap, catchError, of } from 'rxjs';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private http = inject(HttpClient);
private router = inject(Router);
private apiUrl = environment.apiUrl;
token = signal<string | null>(localStorage.getItem('access_token'));
currentUser = signal<any>(null);
login(credentials: { username: string; password: string }) {
const body = new URLSearchParams();
body.set('username', credentials.username);
body.set('password', credentials.password);
return this.http.post<any>(`${this.apiUrl}/token`, body.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
}).pipe(
tap(res => {
localStorage.setItem('access_token', res.access_token);
this.token.set(res.access_token);
this.fetchCurrentUser().subscribe();
})
);
}
fetchCurrentUser() {
return this.http.get<any>(`${this.apiUrl}/users/me`).pipe(
tap(user => this.currentUser.set(user)),
catchError(() => {
this.logout();
return of(null);
})
);
}
logout() {
localStorage.removeItem('access_token');
this.token.set(null);
this.currentUser.set(null);
this.router.navigate(['/login']);
}
isAuthenticated(): boolean {
return !!this.token();
}
}

View File

@ -0,0 +1,36 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class ContractorService {
private http = inject(HttpClient);
private apiUrl = environment.apiUrl + '/contractors';
getContractors(parentId?: number | null, isActive?: boolean, onlyParents: boolean = false) {
const params: any = {};
if (parentId !== undefined && parentId !== null) params.parent_id = parentId;
if (isActive !== undefined && isActive !== null) params.is_active = isActive;
if (onlyParents) params.only_parents = true;
return this.http.get<any[]>(this.apiUrl, { params });
}
getContractor(id: number) {
return this.http.get<any>(`${this.apiUrl}/${id}`);
}
createContractor(contractor: any) {
return this.http.post<any>(this.apiUrl, contractor);
}
updateContractor(id: number, contractor: any) {
return this.http.patch<any>(`${this.apiUrl}/${id}`, contractor);
}
deleteContractor(id: number) {
return this.http.delete(`${this.apiUrl}/${id}`);
}
}

View File

@ -0,0 +1,44 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class NonConformityService {
private http = inject(HttpClient);
private apiUrl = environment.apiUrl + '/non-conformities';
getNCs(activityId?: number, status?: string) {
const params: any = {};
if (activityId) params.activity_id = activityId;
if (status) params.status = status;
return this.http.get<any[]>(this.apiUrl, { params });
}
getNC(id: number) {
return this.http.get<any>(`${this.apiUrl}/${id}`);
}
createNC(nc: any) {
return this.http.post<any>(this.apiUrl, nc);
}
updateNC(id: number, nc: any) {
return this.http.patch<any>(`${this.apiUrl}/${id}`, nc);
}
deleteNC(id: number) {
return this.http.delete(`${this.apiUrl}/${id}`);
}
uploadEvidence(ncId: number, file: File, description?: string, capturedAt?: string) {
const formData = new FormData();
formData.append('file', file);
if (description) formData.append('description', description);
if (capturedAt) formData.append('captured_at', capturedAt);
return this.http.post<any>(`${this.apiUrl}/${ncId}/upload`, formData);
}
}

Some files were not shown because too many files have changed in this diff Show More