Primer commit
This commit is contained in:
commit
e2b9b85a40
|
|
@ -0,0 +1,6 @@
|
|||
**/.DS_Store
|
||||
datasumaq/**
|
||||
backend/venv/**
|
||||
backend/alembic/.DS_Store
|
||||
diseño.md
|
||||
backend/uploads/**
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
Generic single-database configuration.
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
Binary file not shown.
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
|
|
@ -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' }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
<router-outlet></router-outlet>
|
||||
|
|
@ -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' }
|
||||
];
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue