No conformidades relacionadas
This commit is contained in:
parent
e2b9b85a40
commit
0d78fb9ccb
|
|
@ -4,3 +4,4 @@ backend/venv/**
|
||||||
backend/alembic/.DS_Store
|
backend/alembic/.DS_Store
|
||||||
diseño.md
|
diseño.md
|
||||||
backend/uploads/**
|
backend/uploads/**
|
||||||
|
backend/.env
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Google Gemini API Key
|
||||||
|
GOOGLE_API_KEY="*****"
|
||||||
|
|
||||||
|
# Base de Datos (PostgreSQL)
|
||||||
|
DATABASE_URL=postgresql://postgres:******@localhost:5432/postgres
|
||||||
|
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=*****
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=postgres
|
||||||
|
|
||||||
|
# Configuración de Correo (Gmail)
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=*****@gmail.com
|
||||||
|
SMTP_PASSWORD=**** **** **** ****
|
||||||
|
EMAILS_FROM_NAME="Sistema SumaQ"
|
||||||
|
EMAILS_FROM_EMAIL=*****@gmail.com
|
||||||
|
|
||||||
|
# Frontend URL
|
||||||
|
FRONTEND_URL=http://localhost:4200
|
||||||
|
|
@ -17,8 +17,5 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
db = SessionLocal()
|
with SessionLocal() as session:
|
||||||
try:
|
yield session
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ def seed_data():
|
||||||
# Users - Password is 'secret' for everyone
|
# Users - Password is 'secret' for everyone
|
||||||
hashed = get_password_hash("secret")
|
hashed = get_password_hash("secret")
|
||||||
|
|
||||||
admin = User(email="admin@fritosfresh.com", hashed_password=hashed, full_name="Admin User", role=UserRole.ADMIN)
|
admin = User(email="admin@sumaq.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)
|
supervisor = User(email="super@sumaq.com", hashed_password=hashed, full_name="Juan Perez", role=UserRole.SUPERVISOR)
|
||||||
db.add(admin)
|
db.add(admin)
|
||||||
db.add(supervisor)
|
db.add(supervisor)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ if sys.version_info < (3, 10):
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from routers import auth, users, projects, activities, specialties, contractors, transcription, non_conformities
|
from routers import auth, users, projects, activities, specialties, contractors, transcription, non_conformities, guest
|
||||||
import os
|
import os
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
|
@ -34,6 +34,7 @@ app.include_router(specialties.router)
|
||||||
app.include_router(contractors.router)
|
app.include_router(contractors.router)
|
||||||
app.include_router(transcription.router)
|
app.include_router(transcription.router)
|
||||||
app.include_router(non_conformities.router)
|
app.include_router(non_conformities.router)
|
||||||
|
app.include_router(guest.router)
|
||||||
|
|
||||||
# Mount uploads directory to serve files
|
# Mount uploads directory to serve files
|
||||||
if not os.path.exists("uploads"):
|
if not os.path.exists("uploads"):
|
||||||
|
|
|
||||||
|
|
@ -138,14 +138,24 @@ class NonConformity(Base):
|
||||||
|
|
||||||
# New Fields
|
# New Fields
|
||||||
due_date = Column(DateTime, nullable=True)
|
due_date = Column(DateTime, nullable=True)
|
||||||
responsible_person = Column(String, nullable=True)
|
responsible_person = Column(String, nullable=True) # Legend/Name
|
||||||
|
responsible_email = Column(String, nullable=True) # For guest access
|
||||||
|
access_hash = Column(String, unique=True, index=True, nullable=True)
|
||||||
|
contractor_id = Column(Integer, ForeignKey("contractors.id"), nullable=True)
|
||||||
|
|
||||||
action_checklist = Column(JSON, nullable=True) # List of dicts {text: str, completed: bool}
|
action_checklist = Column(JSON, nullable=True) # List of dicts {text: str, completed: bool}
|
||||||
nc_type = Column(Enum(NCType), nullable=True)
|
nc_type = Column(Enum(NCType), nullable=True)
|
||||||
impact_description = Column(Text, nullable=True)
|
impact_description = Column(Text, nullable=True)
|
||||||
closure_description = Column(Text, nullable=True)
|
closure_description = Column(Text, nullable=True)
|
||||||
|
guest_actions = Column(Text, nullable=True) # Field for guest to describe actions taken
|
||||||
|
|
||||||
|
parent_id = Column(Integer, ForeignKey("non_conformities.id"), nullable=True)
|
||||||
|
|
||||||
activity = relationship("Activity", back_populates="non_conformities")
|
activity = relationship("Activity", back_populates="non_conformities")
|
||||||
|
contractor = relationship("Contractor")
|
||||||
evidences = relationship("Evidence", back_populates="non_conformity")
|
evidences = relationship("Evidence", back_populates="non_conformity")
|
||||||
|
parent = relationship("NonConformity", remote_side=[id], back_populates="child_ncs")
|
||||||
|
child_ncs = relationship("NonConformity", back_populates="parent")
|
||||||
|
|
||||||
class Evidence(Base):
|
class Evidence(Base):
|
||||||
__tablename__ = "evidences"
|
__tablename__ = "evidences"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
|
||||||
|
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, Evidence
|
||||||
|
import schemas
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/guest",
|
||||||
|
tags=["Guest Access"]
|
||||||
|
)
|
||||||
|
|
||||||
|
UPLOAD_DIR = "uploads"
|
||||||
|
if not os.path.exists(UPLOAD_DIR):
|
||||||
|
os.makedirs(UPLOAD_DIR)
|
||||||
|
|
||||||
|
@router.get("/nc/{access_hash}", response_model=schemas.NonConformity)
|
||||||
|
def read_guest_nc(
|
||||||
|
access_hash: str,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
db_nc = db.query(NonConformity).filter(NonConformity.access_hash == access_hash).first()
|
||||||
|
if not db_nc:
|
||||||
|
raise HTTPException(status_code=404, detail="Non-Conformity not found or invalid link")
|
||||||
|
return db_nc
|
||||||
|
|
||||||
|
@router.patch("/nc/{access_hash}", response_model=schemas.NonConformity)
|
||||||
|
def update_guest_nc(
|
||||||
|
access_hash: str,
|
||||||
|
nc_update: schemas.NonConformityUpdate,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
db_nc = db.query(NonConformity).filter(NonConformity.access_hash == access_hash).first()
|
||||||
|
if not db_nc:
|
||||||
|
raise HTTPException(status_code=404, detail="Non-Conformity not found or invalid link")
|
||||||
|
|
||||||
|
# Only allow updating specific fields for guest
|
||||||
|
# guest_actions, closure_description? description?
|
||||||
|
# The requirement says: "Describing actions taken." -> guest_actions
|
||||||
|
# "Upload evidence" -> handled by upload endpoint
|
||||||
|
|
||||||
|
# We will trust the validation in schema but we might want to restrict what guests can change.
|
||||||
|
# For now, let's allow updating guest_actions and maybe status if they can close it?
|
||||||
|
# User said: "Add PATCH endpoint to update NC activities/closure by guest."
|
||||||
|
|
||||||
|
if nc_update.guest_actions is not None:
|
||||||
|
db_nc.guest_actions = nc_update.guest_actions
|
||||||
|
|
||||||
|
if nc_update.closure_description is not None:
|
||||||
|
db_nc.closure_description = nc_update.closure_description
|
||||||
|
|
||||||
|
if nc_update.status is not None:
|
||||||
|
# Maybe allow them to mark as "resolved" or something?
|
||||||
|
db_nc.status = nc_update.status
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_nc)
|
||||||
|
return db_nc
|
||||||
|
|
||||||
|
@router.post("/nc/{access_hash}/upload", response_model=schemas.Evidence)
|
||||||
|
async def upload_guest_evidence(
|
||||||
|
access_hash: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
description: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
db_nc = db.query(NonConformity).filter(NonConformity.access_hash == access_hash).first()
|
||||||
|
if not db_nc:
|
||||||
|
raise HTTPException(status_code=404, detail="Non-Conformity not found or invalid link")
|
||||||
|
|
||||||
|
# Generate unique filename
|
||||||
|
file_ext = os.path.splitext(file.filename)[1]
|
||||||
|
unique_filename = f"guest_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)
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
db_evidence = Evidence(
|
||||||
|
non_conformity_id=db_nc.id,
|
||||||
|
file_path=file_path,
|
||||||
|
media_type=file.content_type,
|
||||||
|
description=description,
|
||||||
|
captured_at=datetime.datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(db_evidence)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_evidence)
|
||||||
|
|
||||||
|
return db_evidence
|
||||||
|
|
@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import NonConformity, User, Activity, Evidence
|
from models import NonConformity, User, Activity, Evidence, Contractor
|
||||||
from security import get_current_active_user
|
from security import get_current_active_user
|
||||||
import schemas
|
import schemas
|
||||||
|
|
||||||
|
|
@ -29,6 +29,12 @@ def create_nc(
|
||||||
if not db_activity:
|
if not db_activity:
|
||||||
raise HTTPException(status_code=404, detail="Activity not found")
|
raise HTTPException(status_code=404, detail="Activity not found")
|
||||||
|
|
||||||
|
# Sync responsible email from contractor if not provided
|
||||||
|
if nc.contractor_id and not nc.responsible_email:
|
||||||
|
contractor = db.query(Contractor).filter(Contractor.id == nc.contractor_id).first()
|
||||||
|
if contractor and contractor.email:
|
||||||
|
nc.responsible_email = contractor.email
|
||||||
|
|
||||||
db_nc = NonConformity(**nc.dict())
|
db_nc = NonConformity(**nc.dict())
|
||||||
db.add(db_nc)
|
db.add(db_nc)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -74,6 +80,14 @@ def update_nc(
|
||||||
raise HTTPException(status_code=404, detail="Non-Conformity not found")
|
raise HTTPException(status_code=404, detail="Non-Conformity not found")
|
||||||
|
|
||||||
update_data = nc.dict(exclude_unset=True)
|
update_data = nc.dict(exclude_unset=True)
|
||||||
|
|
||||||
|
# Sync responsible email if contractor_id changes and email not explicitly provided
|
||||||
|
if 'contractor_id' in update_data and update_data['contractor_id']:
|
||||||
|
if 'responsible_email' not in update_data or not update_data['responsible_email']:
|
||||||
|
contractor = db.query(Contractor).filter(Contractor.id == update_data['contractor_id']).first()
|
||||||
|
if contractor and contractor.email:
|
||||||
|
update_data['responsible_email'] = contractor.email
|
||||||
|
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
setattr(db_nc, key, value)
|
setattr(db_nc, key, value)
|
||||||
|
|
||||||
|
|
@ -140,3 +154,33 @@ def delete_nc(
|
||||||
db.delete(db_nc)
|
db.delete(db_nc)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"detail": "Non-Conformity deleted"}
|
return {"detail": "Non-Conformity deleted"}
|
||||||
|
|
||||||
|
@router.post("/{nc_id}/notify")
|
||||||
|
def notify_responsible(
|
||||||
|
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")
|
||||||
|
|
||||||
|
if not db_nc.responsible_email:
|
||||||
|
raise HTTPException(status_code=400, detail="No responsible email configured for this Non-Conformity")
|
||||||
|
|
||||||
|
# Generate hash if it doesn't exist
|
||||||
|
if not db_nc.access_hash:
|
||||||
|
db_nc.access_hash = str(uuid.uuid4())
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_nc)
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
from services.email_service import EmailService
|
||||||
|
EmailService.send_nc_notification(db_nc.responsible_email, db_nc.access_hash, db_nc.description)
|
||||||
|
|
||||||
|
# Update status to in-checking
|
||||||
|
db_nc.status = 'in-checking'
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_nc)
|
||||||
|
|
||||||
|
return {"message": "Notification sent successfully", "access_hash": db_nc.access_hash, "status": db_nc.status}
|
||||||
|
|
|
||||||
|
|
@ -154,29 +154,37 @@ class NonConformityBase(BaseModel):
|
||||||
status: str = "open"
|
status: str = "open"
|
||||||
due_date: Optional[datetime] = None
|
due_date: Optional[datetime] = None
|
||||||
responsible_person: Optional[str] = None
|
responsible_person: Optional[str] = None
|
||||||
|
responsible_email: Optional[str] = None
|
||||||
|
contractor_id: Optional[int] = None
|
||||||
|
access_hash: Optional[str] = None
|
||||||
action_checklist: Optional[List[dict]] = None
|
action_checklist: Optional[List[dict]] = None
|
||||||
nc_type: Optional[NCType] = None
|
nc_type: Optional[NCType] = None
|
||||||
impact_description: Optional[str] = None
|
impact_description: Optional[str] = None
|
||||||
closure_description: Optional[str] = None
|
closure_description: Optional[str] = None
|
||||||
|
guest_actions: Optional[str] = None
|
||||||
|
parent_id: Optional[int] = None
|
||||||
|
|
||||||
class NonConformityCreate(NonConformityBase):
|
class NonConformityCreate(NonConformityBase):
|
||||||
activity_id: int
|
activity_id: int
|
||||||
|
|
||||||
class NonConformityUpdate(BaseModel):
|
class NonConformityUpdate(BaseModel):
|
||||||
level: Optional[NCLevel] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
status: Optional[str] = None
|
|
||||||
due_date: Optional[datetime] = None
|
due_date: Optional[datetime] = None
|
||||||
responsible_person: Optional[str] = None
|
responsible_person: Optional[str] = None
|
||||||
|
responsible_email: Optional[str] = None
|
||||||
|
contractor_id: Optional[int] = None
|
||||||
|
access_hash: Optional[str] = None
|
||||||
action_checklist: Optional[List[dict]] = None
|
action_checklist: Optional[List[dict]] = None
|
||||||
nc_type: Optional[NCType] = None
|
nc_type: Optional[NCType] = None
|
||||||
impact_description: Optional[str] = None
|
impact_description: Optional[str] = None
|
||||||
closure_description: Optional[str] = None
|
closure_description: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
guest_actions: Optional[str] = None
|
||||||
|
|
||||||
class NonConformity(NonConformityBase):
|
class NonConformity(NonConformityBase):
|
||||||
id: int
|
id: int
|
||||||
activity_id: int
|
activity_id: int
|
||||||
evidences: List[Evidence] = []
|
evidences: List[Evidence] = []
|
||||||
|
child_ncs: List['NonConformity'] = []
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
|
||||||
|
import smtplib
|
||||||
|
import os
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.header import Header
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
class EmailService:
|
||||||
|
@staticmethod
|
||||||
|
def send_nc_notification(email: str, access_hash: str, nc_description: str):
|
||||||
|
smtp_host = os.getenv("SMTP_HOST", "smtp.gmail.com")
|
||||||
|
smtp_port = int(os.getenv("SMTP_PORT", 587))
|
||||||
|
smtp_user = os.getenv("SMTP_USER")
|
||||||
|
smtp_password = os.getenv("SMTP_PASSWORD")
|
||||||
|
from_name = os.getenv("EMAILS_FROM_NAME", "Sistema de Supervisión")
|
||||||
|
from_email = os.getenv("EMAILS_FROM_EMAIL", smtp_user)
|
||||||
|
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:4200")
|
||||||
|
|
||||||
|
if not smtp_user or not smtp_password:
|
||||||
|
print("WARNING: Email credentials not configured. Simulation mode.")
|
||||||
|
return EmailService._simulate_send(email, access_hash, nc_description, frontend_url)
|
||||||
|
|
||||||
|
subject = "Acción Requerida: No Conformidad Asignada"
|
||||||
|
link = f"{frontend_url}/nc-guest/{access_hash}"
|
||||||
|
|
||||||
|
body = f"""
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #ddd; border-radius: 10px;">
|
||||||
|
<h2 style="color: #b45309;">Gestión de No Conformidades</h2>
|
||||||
|
<p>Estimado/a responsable,</p>
|
||||||
|
<p>Se le ha asignado una <strong>No Conformidad</strong> con la siguiente descripción:</p>
|
||||||
|
<blockquote style="background: #f9f9f9; border-left: 5px solid #ccc; padding: 10px; margin: 20px 0;">
|
||||||
|
{nc_description}
|
||||||
|
</blockquote>
|
||||||
|
<p>Por favor, haga clic en el siguiente enlace para revisar los detalles y registrar las acciones tomadas:</p>
|
||||||
|
<p style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{link}" style="background-color: #b45309; color: white; padding: 12px 25px; text-decoration: none; border-radius: 5px; font-weight: bold;">
|
||||||
|
Revisar No Conformidad
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 0.8em; color: #777;">Si el botón no funciona, copie y pegue el siguiente enlace en su navegador:<br>{link}</p>
|
||||||
|
<hr style="border: 0; border-top: 1px solid #eee; margin: 30px 0;">
|
||||||
|
<p style="font-size: 0.9em;">Este es un mensaje automático, por favor no responda directamente.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg['From'] = f"{Header(from_name, 'utf-8').encode()} <{from_email}>"
|
||||||
|
msg['To'] = email
|
||||||
|
msg['Subject'] = Header(subject, 'utf-8')
|
||||||
|
msg.attach(MIMEText(body, 'html', 'utf-8'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
server = smtplib.SMTP(smtp_host, smtp_port)
|
||||||
|
server.starttls()
|
||||||
|
server.login(smtp_user, smtp_password)
|
||||||
|
text = msg.as_string()
|
||||||
|
server.sendmail(from_email, email, text)
|
||||||
|
server.quit()
|
||||||
|
print(f"Email sent successfully to {email}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error sending email: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _simulate_send(email: str, access_hash: str, nc_description: str, frontend_url: str):
|
||||||
|
link = f"{frontend_url}/nc-guest/{access_hash}"
|
||||||
|
print(f"================ SIMULATION ================")
|
||||||
|
print(f"TO: {email}")
|
||||||
|
print(f"LINK: {link}")
|
||||||
|
print(f"DESC: {nc_description}")
|
||||||
|
print(f"============================================")
|
||||||
|
return True
|
||||||
Binary file not shown.
|
|
@ -12,8 +12,11 @@ import { ContractorListComponent } from './components/contractor-list/contractor
|
||||||
import { ContractorFormComponent } from './components/contractor-form/contractor-form';
|
import { ContractorFormComponent } from './components/contractor-form/contractor-form';
|
||||||
import { authGuard } from './guards/auth';
|
import { authGuard } from './guards/auth';
|
||||||
|
|
||||||
|
import { NCGuestComponent } from './components/nc-guest/nc-guest';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: 'login', component: LoginComponent },
|
{ path: 'login', component: LoginComponent },
|
||||||
|
{ path: 'nc-guest/:hash', component: NCGuestComponent },
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
component: LayoutComponent,
|
component: LayoutComponent,
|
||||||
|
|
@ -27,6 +30,7 @@ export const routes: Routes = [
|
||||||
{ path: 'projects/new', component: ProjectFormComponent },
|
{ path: 'projects/new', component: ProjectFormComponent },
|
||||||
{ path: 'projects/edit/:id', component: ProjectFormComponent },
|
{ path: 'projects/edit/:id', component: ProjectFormComponent },
|
||||||
{ path: 'non-conformities', component: NonConformityListComponent },
|
{ path: 'non-conformities', component: NonConformityListComponent },
|
||||||
|
{ path: 'non-conformities/new', component: NonConformityFormComponent },
|
||||||
{ path: 'non-conformities/edit/:id', component: NonConformityFormComponent },
|
{ path: 'non-conformities/edit/:id', component: NonConformityFormComponent },
|
||||||
{ path: 'contractors', component: ContractorListComponent },
|
{ path: 'contractors', component: ContractorListComponent },
|
||||||
{ path: 'contractors/new', component: ContractorFormComponent },
|
{ path: 'contractors/new', component: ContractorFormComponent },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
.calendar-wrapper {
|
.calendar-wrapper {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border-radius: 20px;
|
border-radius: var(--radius-md);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
|
@ -73,7 +73,7 @@
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 16px;
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
|
|
@ -110,7 +110,7 @@
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 16px;
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,8 +103,7 @@
|
||||||
<div class="evidence-grid-dynamic">
|
<div class="evidence-grid-dynamic">
|
||||||
<div class="evidence-card-editable" *ngFor="let ev of existingEvidences">
|
<div class="evidence-card-editable" *ngFor="let ev of existingEvidences">
|
||||||
<div class="card-media" (click)="openPreview(ev)">
|
<div class="card-media" (click)="openPreview(ev)">
|
||||||
<img [src]="'http://192.168.1.74:8000/' + ev.file_path"
|
<img [src]="apiUrl + '/' + ev.file_path" *ngIf="ev.media_type.startsWith('image/')">
|
||||||
*ngIf="ev.media_type.startsWith('image/')">
|
|
||||||
<div class="placeholder audio" *ngIf="ev.media_type.startsWith('audio/')">
|
<div class="placeholder audio" *ngIf="ev.media_type.startsWith('audio/')">
|
||||||
<lucide-icon [img]="FileAudio" size="32"></lucide-icon>
|
<lucide-icon [img]="FileAudio" size="32"></lucide-icon>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border-radius: 16px;
|
border-radius: var(--radius-sm);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
|
@ -352,7 +352,7 @@
|
||||||
.evidence-card-editable {
|
.evidence-card-editable {
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 16px;
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -477,7 +477,7 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
border: 2px dashed #e2e8f0;
|
border: 2px dashed #e2e8f0;
|
||||||
border-radius: 16px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -521,7 +521,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 16px;
|
border-radius: var(--radius-sm);
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -570,7 +570,7 @@
|
||||||
.preview-item-expanded {
|
.preview-item-expanded {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 16px;
|
border-radius: var(--radius-sm);
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
|
|
@ -684,7 +684,7 @@
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 20px;
|
border-radius: var(--radius-sm);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
|
@ -798,7 +798,7 @@
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border: 2px dashed var(--brand-primary);
|
border: 2px dashed var(--brand-primary);
|
||||||
border-radius: 20px;
|
border-radius: var(--radius-md);
|
||||||
background: rgba(180, 83, 9, 0.02);
|
background: rgba(180, 83, 9, 0.02);
|
||||||
color: var(--brand-primary);
|
color: var(--brand-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -900,138 +900,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgb(15 23 42 / 18%);
|
|
||||||
backdrop-filter: blur(3px);
|
|
||||||
z-index: 9999;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 40px;
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
background: white;
|
|
||||||
border-radius: 24px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 900px;
|
|
||||||
max-height: 85vh;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
height: 100vh;
|
|
||||||
max-height: 100vh;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-modal {
|
|
||||||
position: absolute;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
z-index: 10;
|
|
||||||
background: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
color: #64748b;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #1e293b;
|
|
||||||
transform: rotate(90deg) scale(1.1);
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-media {
|
|
||||||
flex: 1;
|
|
||||||
//background: var(--brand-primary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 1rem;
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
video {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
audio {
|
|
||||||
width: 90%;
|
|
||||||
max-width: 500px;
|
|
||||||
//filter: invert(1) hue-rotate(180deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
padding: 24px;
|
|
||||||
background: white;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
border-top: 1px solid #f1f5f9;
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #1e293b;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--brand-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: rgba(180, 83, 9, 0.05);
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(180, 83, 9, 0.1);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 16px;
|
|
||||||
|
|
||||||
.download-link {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0% {
|
0% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators, FormsModule }
|
||||||
import { ActivityService } from '../../services/activity';
|
import { ActivityService } from '../../services/activity';
|
||||||
import { ProjectService } from '../../services/project';
|
import { ProjectService } from '../../services/project';
|
||||||
import { TranscriptionService } from '../../services/transcription';
|
import { TranscriptionService } from '../../services/transcription';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
LucideAngularModule, ArrowLeft, Upload, X, Shield, Mic, Square, Loader,
|
LucideAngularModule, ArrowLeft, Upload, X, Shield, Mic, Square, Loader,
|
||||||
|
|
@ -71,6 +72,8 @@ export class ActivityFormComponent implements OnInit {
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
|
apiUrl: String = environment.apiUrl;
|
||||||
|
|
||||||
activityForm: FormGroup;
|
activityForm: FormGroup;
|
||||||
projects = signal<any[]>([]);
|
projects = signal<any[]>([]);
|
||||||
selectedFiles: EvidenceItem[] = [];
|
selectedFiles: EvidenceItem[] = [];
|
||||||
|
|
@ -240,7 +243,7 @@ export class ActivityFormComponent implements OnInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
openPreview(ev: any) {
|
openPreview(ev: any) {
|
||||||
const baseUrl = 'http://192.168.1.74:8000/';
|
const baseUrl = environment.apiUrl + '/';
|
||||||
this.previewModal.set({
|
this.previewModal.set({
|
||||||
type: ev.media_type,
|
type: ev.media_type,
|
||||||
url: baseUrl + ev.file_path,
|
url: baseUrl + ev.file_path,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border-radius: 16px;
|
border-radius: var(--radius-sm);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
|
|
||||||
.filter-group {
|
.filter-group {
|
||||||
|
|
@ -88,7 +88,7 @@
|
||||||
|
|
||||||
.kanban-column {
|
.kanban-column {
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
border-radius: 20px;
|
border-radius: var(--radius-md);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: calc(100vh - 350px);
|
min-height: calc(100vh - 350px);
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,7 @@ select {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
background: var(--bg-main);
|
background: var(--bg-main);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 16px;
|
border-radius: var(--radius-sm);
|
||||||
|
|
||||||
.add-btn {
|
.add-btn {
|
||||||
background: white;
|
background: white;
|
||||||
|
|
@ -244,7 +244,7 @@ select {
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 16px;
|
border-radius: var(--radius-sm);
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|
@ -280,7 +280,7 @@ select {
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
border-radius: 16px;
|
border-radius: var(--radius-sm);
|
||||||
border: 2px dashed var(--border-color);
|
border: 2px dashed var(--border-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 20px;
|
border-radius: var(--radius-md);
|
||||||
background: #f1f5f9;
|
background: #f1f5f9;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 05px 30px;
|
padding: 05px 30px;
|
||||||
background-color: #f0eae6;
|
//background-color: #f0eae6;
|
||||||
border-radius: 0.7rem;
|
border-radius: 0.7rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export class LoginComponent {
|
||||||
private authService = inject(AuthService);
|
private authService = inject(AuthService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
username = 'admin@fritosfresh.com';
|
username = 'admin@sumaq.com';
|
||||||
password = 'secret';
|
password = 'secret';
|
||||||
loading = signal(false);
|
loading = signal(false);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
<div class="guest-container" *ngIf="nc()">
|
||||||
|
<div class="logo flex gap-1 center">
|
||||||
|
<img src="logo.png" alt="Logo" style="width: 48px; height: 48px;">
|
||||||
|
<div style="font-size: 2rem;">
|
||||||
|
<span>Suma</span><span style="color: var(--brand-primary);font-weight: 800;">Q</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="pt-2">Resolución de No Conformidad</h1>
|
||||||
|
|
||||||
|
<div class="nc-details-card">
|
||||||
|
<div class="field">
|
||||||
|
<label>Descripción del Hallazgo</label>
|
||||||
|
<p class="value">{{ nc().description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Nivel</label>
|
||||||
|
<span class="value">{{ nc().level }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Tipo de no Conformidad</label>
|
||||||
|
<span class="value">{{ nc().nc_type }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-section">
|
||||||
|
<h2>Reporte de Acciones</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Descripción de las acciones tomadas</label>
|
||||||
|
<textarea [(ngModel)]="actionsText" rows="5"
|
||||||
|
placeholder="Describa cómo se resolvió el hallazgo..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="evidence-section">
|
||||||
|
<label>Sustento Visual (Fotos/Documentos)</label>
|
||||||
|
<div class="upload-controls">
|
||||||
|
<button type="button" class="upload-btn" (click)="fileInput.click()">
|
||||||
|
<lucide-icon [img]="FileText" size="18"></lucide-icon>
|
||||||
|
Adjuntar Archivos
|
||||||
|
</button>
|
||||||
|
<input type="file" #fileInput hidden (change)="onFileSelected($event)" multiple
|
||||||
|
accept="image/*,.pdf,.doc,.docx">
|
||||||
|
</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" *ngIf="item.file.type.startsWith('image/')">
|
||||||
|
<div class="file-icon-placeholder" *ngIf="!item.file.type.startsWith('image/')">
|
||||||
|
<lucide-icon [img]="FileText" size="32"></lucide-icon>
|
||||||
|
<span>{{ item.file.name }}</span>
|
||||||
|
</div>
|
||||||
|
<textarea [(ngModel)]="item.description" placeholder="Nota sobre este archivo..."></textarea>
|
||||||
|
<button type="button" class="remove-btn" (click)="removeFile(i)">
|
||||||
|
<lucide-icon [img]="Trash2" size="14"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="current-evidences" *ngIf="nc().evidences?.length">
|
||||||
|
<label>Evidencias registradas</label>
|
||||||
|
<div class="evidence-grid">
|
||||||
|
<div class="evidence-item" *ngFor="let ev of nc().evidences">
|
||||||
|
<img [src]="apiUrl +'/'+ ev.file_path" *ngIf="ev.media_type?.startsWith('image/')">
|
||||||
|
<div class="file-link" *ngIf="!ev.media_type?.startsWith('image/')">
|
||||||
|
<lucide-icon [img]="FileText" size="20"></lucide-icon>
|
||||||
|
<a [href]="apiUrl + '/' + ev.file_path" target="_blank">Ver Documento</a>
|
||||||
|
</div>
|
||||||
|
<p class="ev-desc">{{ ev.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="gold-button" (click)="submit()" [disabled]="loading()">
|
||||||
|
{{ loading() ? 'Enviando...' : 'Enviar Reporte Final' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="guest-container" *ngIf="!nc() && !loading()">
|
||||||
|
<h1>Enlace no válido o expirado</h1>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
.guest-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: var(--brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-details-card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-main);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-main);
|
||||||
|
resize: vertical;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--brand-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn {
|
||||||
|
background: var(--bg-main);
|
||||||
|
border: 2px dashed var(--border-color);
|
||||||
|
color: var(--brand-primary);
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-color: var(--brand-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-previews {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
|
||||||
|
.preview-item {
|
||||||
|
position: relative;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon-placeholder {
|
||||||
|
height: 100px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-top: 5px;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
right: -5px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-evidences {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
.evidence-item {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-link {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--brand-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ev-desc {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
|
||||||
|
&.gold-button {
|
||||||
|
background: var(--brand-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 20px rgba(180, 83, 9, 0.4);
|
||||||
|
background: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
|
||||||
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { LucideAngularModule, Camera, FileText, X, Trash2, CheckCircle, Clock } from 'lucide-angular';
|
||||||
|
import { forkJoin } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-nc-guest',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||||
|
templateUrl: './nc-guest.html',
|
||||||
|
styleUrl: './nc-guest.scss'
|
||||||
|
})
|
||||||
|
export class NCGuestComponent implements OnInit {
|
||||||
|
readonly Camera = Camera;
|
||||||
|
readonly FileText = FileText;
|
||||||
|
readonly X = X;
|
||||||
|
readonly Trash2 = Trash2;
|
||||||
|
readonly CheckCircle = CheckCircle;
|
||||||
|
readonly Clock = Clock;
|
||||||
|
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
apiUrl = environment.apiUrl;
|
||||||
|
|
||||||
|
nc = signal<any>(null);
|
||||||
|
loading = signal(false);
|
||||||
|
actionsText = '';
|
||||||
|
hash = '';
|
||||||
|
|
||||||
|
selectedFiles = signal<any[]>([]); // { file, previewUrl, description }
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.hash = this.route.snapshot.paramMap.get('hash') || '';
|
||||||
|
if (this.hash) {
|
||||||
|
this.loadNC();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadNC() {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.http.get<any>(`${this.apiUrl}/guest/nc/${this.hash}`).subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.nc.set(data);
|
||||||
|
this.actionsText = data.guest_actions || '';
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => this.loading.set(false)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileSelected(event: any) {
|
||||||
|
const files: FileList = event.target.files;
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e: any) => {
|
||||||
|
this.selectedFiles.update(prev => [...prev, {
|
||||||
|
file: file,
|
||||||
|
previewUrl: e.target.result,
|
||||||
|
description: ''
|
||||||
|
}]);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFile(index: number) {
|
||||||
|
this.selectedFiles.update(list => list.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
submit() {
|
||||||
|
if (!this.actionsText.trim()) {
|
||||||
|
alert('Por favor describa las acciones tomadas.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading.set(true);
|
||||||
|
const body = {
|
||||||
|
guest_actions: this.actionsText,
|
||||||
|
status: 'in-checking'
|
||||||
|
};
|
||||||
|
|
||||||
|
this.http.patch(`${this.apiUrl}/guest/nc/${this.hash}`, body).subscribe({
|
||||||
|
next: (updated) => {
|
||||||
|
if (this.selectedFiles().length > 0) {
|
||||||
|
const uploads = this.selectedFiles().map(f => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', f.file);
|
||||||
|
if (f.description) formData.append('description', f.description);
|
||||||
|
return this.http.post(`${this.apiUrl}/guest/nc/${this.hash}/upload`, formData);
|
||||||
|
});
|
||||||
|
|
||||||
|
forkJoin(uploads).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.finishSubmission(updated);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
alert('Reporte actualizado, pero hubo un error al subir algunas evidencias.');
|
||||||
|
this.finishSubmission(updated);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.finishSubmission(updated);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
alert('Error al enviar el reporte.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishSubmission(updated: any) {
|
||||||
|
this.nc.set(updated);
|
||||||
|
this.selectedFiles.set([]);
|
||||||
|
this.loading.set(false);
|
||||||
|
alert('Reporte y evidencias enviadas correctamente.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<button type="button" class="gold-button" (click)="generateRelatedNC()" *ngIf="ncId"
|
||||||
|
title="Generar NC Relacionada">
|
||||||
|
<lucide-icon [img]="Plus" size="20"></lucide-icon>
|
||||||
|
<span>Nueva no conformidad</span>
|
||||||
|
</button>
|
||||||
<button type="button" class="cancel-btn" routerLink="/non-conformities">Cancelar</button>
|
<button type="button" class="cancel-btn" routerLink="/non-conformities">Cancelar</button>
|
||||||
<button type="submit" form="ncForm" class="gold-button" [disabled]="loading()">
|
<button type="submit" form="ncForm" class="gold-button" [disabled]="loading()">
|
||||||
<span *ngIf="!loading()">Guardar Cambios</span>
|
<span *ngIf="!loading()">Guardar Cambios</span>
|
||||||
|
|
@ -33,6 +38,7 @@
|
||||||
<select formControlName="status" class="status-select" [class]="ncForm.get('status')?.value">
|
<select formControlName="status" class="status-select" [class]="ncForm.get('status')?.value">
|
||||||
<option value="open">Abierto</option>
|
<option value="open">Abierto</option>
|
||||||
<option value="in-progress">En Proceso</option>
|
<option value="in-progress">En Proceso</option>
|
||||||
|
<option value="in-checking">En Revisión</option>
|
||||||
<option value="re-inspect">Re-Inspección</option>
|
<option value="re-inspect">Re-Inspección</option>
|
||||||
<option value="resolved">Resuelto</option>
|
<option value="resolved">Resuelto</option>
|
||||||
<option value="closed">Cerrado</option>
|
<option value="closed">Cerrado</option>
|
||||||
|
|
@ -66,6 +72,18 @@
|
||||||
<option *ngFor="let t of ncTypes" [value]="t">{{ t }}</option>
|
<option *ngFor="let t of ncTypes" [value]="t">{{ t }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Empresa Contratista</label>
|
||||||
|
<div class="input-with-icon">
|
||||||
|
<lucide-icon [img]="Building" size="16"></lucide-icon>
|
||||||
|
<select formControlName="contractor_id" (change)="onContractorChange()">
|
||||||
|
<option [value]="null">Seleccione contratista</option>
|
||||||
|
<option *ngFor="let c of contractors()" [value]="c.id">{{ c.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Responsable</label>
|
<label>Responsable</label>
|
||||||
<div class="input-with-icon">
|
<div class="input-with-icon">
|
||||||
|
|
@ -73,6 +91,21 @@
|
||||||
<input type="text" formControlName="responsible_person" placeholder="Nombre del responsable">
|
<input type="text" formControlName="responsible_person" placeholder="Nombre del responsable">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email del Responsable</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<div class="input-with-icon flex-grow">
|
||||||
|
<lucide-icon [img]="Send" size="16"></lucide-icon>
|
||||||
|
<input type="email" formControlName="responsible_email" placeholder="email@empresa.com">
|
||||||
|
</div>
|
||||||
|
<button type="button" class="icon-btn" (click)="notifyResponsible()" title="Enviar Notificación"
|
||||||
|
*ngIf="ncId">
|
||||||
|
<lucide-icon [img]="Send" size="18"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Fecha Límite</label>
|
<label>Fecha Límite</label>
|
||||||
<div class="input-with-icon">
|
<div class="input-with-icon">
|
||||||
|
|
@ -138,7 +171,7 @@
|
||||||
<div class="existing-grid" *ngIf="existingEvidences().length > 0">
|
<div class="existing-grid" *ngIf="existingEvidences().length > 0">
|
||||||
<div class="evidence-thumb" *ngFor="let ev of existingEvidences()">
|
<div class="evidence-thumb" *ngFor="let ev of existingEvidences()">
|
||||||
<div class="thumb-media" (click)="openPreview(ev)">
|
<div class="thumb-media" (click)="openPreview(ev)">
|
||||||
<img [src]="'http://192.168.1.74:8000/' + ev.file_path">
|
<img [src]="apiUrl + '/'+ ev.file_path">
|
||||||
<div class="overlay">
|
<div class="overlay">
|
||||||
<lucide-icon [img]="Maximize2" size="20"></lucide-icon>
|
<lucide-icon [img]="Maximize2" size="20"></lucide-icon>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -177,6 +210,24 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Related Non-Conformities -->
|
||||||
|
<div class="premium-card related-ncs-card" *ngIf="ncId && nc()?.child_ncs?.length > 0">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>No Conformidades Relacionadas</h3>
|
||||||
|
<p>Nuevos hallazgos originados a partir de esta NC.</p>
|
||||||
|
</div>
|
||||||
|
<div class="related-list">
|
||||||
|
<div class="related-item" *ngFor="let child of nc().child_ncs" (click)="openNcRelated(child.id)">
|
||||||
|
<div class="item-info">
|
||||||
|
<span class="id">#{{ child.id }}</span>
|
||||||
|
<span class="desc">{{ child.description }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="badge" [class]="child.status">{{ child.status }}</span>
|
||||||
|
<lucide-icon [img]="ArrowLeft" style="transform: rotate(180deg);" size="16"></lucide-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,11 @@ textarea {
|
||||||
background: rgba(59, 130, 246, 0.05);
|
background: rgba(59, 130, 246, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.in-checking {
|
||||||
|
color: #6366f1;
|
||||||
|
background: rgba(99, 102, 241, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
&.re-inspect {
|
&.re-inspect {
|
||||||
color: #f59e0b;
|
color: #f59e0b;
|
||||||
background: rgba(245, 158, 11, 0.05);
|
background: rgba(245, 158, 11, 0.05);
|
||||||
|
|
@ -292,7 +297,7 @@ textarea {
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 16px;
|
border-radius: var(--radius-sm);
|
||||||
border: 2px dashed var(--border-color);
|
border: 2px dashed var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -302,7 +307,7 @@ textarea {
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: var(--bg-main);
|
background: var(--bg-main);
|
||||||
border-radius: 16px;
|
border-radius: var(--radius-sm);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
|
@ -369,7 +374,7 @@ textarea {
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
|
|
||||||
.evidence-thumb {
|
.evidence-thumb {
|
||||||
border-radius: 16px;
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
|
@ -457,7 +462,7 @@ textarea {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: var(--bg-main);
|
background: var(--bg-main);
|
||||||
border: 2px dashed var(--border-color);
|
border: 2px dashed var(--border-color);
|
||||||
border-radius: 20px;
|
border-radius: var(--radius-md);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
@ -477,15 +482,6 @@ textarea {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.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-overlay {
|
||||||
.camera-actions {
|
.camera-actions {
|
||||||
.photo {
|
.photo {
|
||||||
|
|
@ -523,3 +519,106 @@ textarea {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.flex-grow {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--brand-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(180, 83, 9, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.related-ncs-card {
|
||||||
|
.related-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-main);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--brand-primary);
|
||||||
|
background: white;
|
||||||
|
transform: translateX(4px);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.id {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brand-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.in-progress {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.resolved {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,10 +4,12 @@ import { RouterModule, ActivatedRoute, Router } from '@angular/router';
|
||||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { NonConformityService } from '../../services/non-conformity';
|
import { NonConformityService } from '../../services/non-conformity';
|
||||||
import { ActivityService } from '../../services/activity';
|
import { ActivityService } from '../../services/activity';
|
||||||
|
import { ContractorService } from '../../services/contractor';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
LucideAngularModule, ArrowLeft, TriangleAlert, CircleCheck, Clock, ListPlus, X,
|
LucideAngularModule, ArrowLeft, TriangleAlert, CircleCheck, Clock, ListPlus, X,
|
||||||
Save, Camera, Trash2, Calendar, User, FileText, Maximize2, ExternalLink, RefreshCw
|
Save, Camera, Trash2, Calendar, User, FileText, Maximize2, ExternalLink, RefreshCw, Send, Building
|
||||||
} from 'lucide-angular';
|
} from 'lucide-angular';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|
@ -34,19 +36,28 @@ export class NonConformityFormComponent implements OnInit {
|
||||||
readonly Maximize2 = Maximize2;
|
readonly Maximize2 = Maximize2;
|
||||||
readonly ExternalLink = ExternalLink;
|
readonly ExternalLink = ExternalLink;
|
||||||
readonly RefreshCw = RefreshCw;
|
readonly RefreshCw = RefreshCw;
|
||||||
|
readonly Send = Send;
|
||||||
|
readonly Building = Building;
|
||||||
|
|
||||||
private fb = inject(FormBuilder);
|
private fb = inject(FormBuilder);
|
||||||
private ncService = inject(NonConformityService);
|
private ncService = inject(NonConformityService);
|
||||||
private activityService = inject(ActivityService);
|
private activityService = inject(ActivityService);
|
||||||
|
private contractorService = inject(ContractorService);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
|
apiUrl: String = environment.apiUrl;
|
||||||
|
|
||||||
ncId: number | null = null;
|
ncId: number | null = null;
|
||||||
|
parentId: number | null = null;
|
||||||
|
activityId: number | null = null;
|
||||||
loading = signal(false);
|
loading = signal(false);
|
||||||
|
nc = signal<any>(null);
|
||||||
ncForm: FormGroup;
|
ncForm: FormGroup;
|
||||||
|
|
||||||
// Dynamic collections
|
// Dynamic collections
|
||||||
checklist = signal<any[]>([]);
|
checklist = signal<any[]>([]);
|
||||||
|
contractors = signal<any[]>([]);
|
||||||
newActionText = '';
|
newActionText = '';
|
||||||
|
|
||||||
existingEvidences = signal<any[]>([]);
|
existingEvidences = signal<any[]>([]);
|
||||||
|
|
@ -75,6 +86,8 @@ export class NonConformityFormComponent implements OnInit {
|
||||||
status: ['open', Validators.required],
|
status: ['open', Validators.required],
|
||||||
due_date: [null],
|
due_date: [null],
|
||||||
responsible_person: [''],
|
responsible_person: [''],
|
||||||
|
responsible_email: [''],
|
||||||
|
contractor_id: [null],
|
||||||
nc_type: [null],
|
nc_type: [null],
|
||||||
impact_description: [''],
|
impact_description: [''],
|
||||||
closure_description: ['']
|
closure_description: ['']
|
||||||
|
|
@ -82,9 +95,77 @@ export class NonConformityFormComponent implements OnInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.ncId = Number(this.route.snapshot.paramMap.get('id'));
|
this.route.paramMap.subscribe(params => {
|
||||||
if (this.ncId) {
|
this.ncId = Number(params.get('id'));
|
||||||
this.loadNC(this.ncId);
|
|
||||||
|
// Reset state if needed
|
||||||
|
if (!this.ncId) {
|
||||||
|
this.ncForm.reset({ level: 'minor', status: 'open' });
|
||||||
|
this.nc.set(null);
|
||||||
|
this.checklist.set([]);
|
||||||
|
this.existingEvidences.set([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.ncId) {
|
||||||
|
this.loadNC(this.ncId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.route.queryParamMap.subscribe(params => {
|
||||||
|
this.parentId = Number(params.get('parentId')) || null;
|
||||||
|
if (this.parentId && !this.ncId) {
|
||||||
|
this.loadParentContext(this.parentId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.loadContractors();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadParentContext(parentId: number) {
|
||||||
|
this.ncService.getNC(parentId).subscribe(parent => {
|
||||||
|
this.activityId = parent.activity_id;
|
||||||
|
this.ncForm.patchValue({
|
||||||
|
level: parent.level,
|
||||||
|
contractor_id: parent.contractor_id,
|
||||||
|
nc_type: parent.nc_type
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadContractors() {
|
||||||
|
this.contractorService.getContractors().subscribe(data => this.contractors.set(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
onContractorChange() {
|
||||||
|
const contractorId = this.ncForm.get('contractor_id')?.value;
|
||||||
|
if (contractorId) {
|
||||||
|
const contractor = this.contractors().find(c => c.id == contractorId);
|
||||||
|
if (contractor) {
|
||||||
|
if (contractor.email && !this.ncForm.get('responsible_email')?.value) {
|
||||||
|
this.ncForm.patchValue({ responsible_email: contractor.email });
|
||||||
|
}
|
||||||
|
if (contractor.contact_name && !this.ncForm.get('responsible_person')?.value) {
|
||||||
|
this.ncForm.patchValue({ responsible_person: contractor.contact_name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyResponsible() {
|
||||||
|
if (!this.ncId) return;
|
||||||
|
if (confirm('¿Enviar notificación al responsable por correo?')) {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.ncService.notifyResponsible(this.ncId).subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.ncForm.patchValue({ status: 'in-progress' });
|
||||||
|
alert('Notificación enviada y estado actualizado a "Revision".');
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.loading.set(false);
|
||||||
|
alert('Error al enviar notificación: ' + (err.error?.detail || err.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,12 +173,15 @@ export class NonConformityFormComponent implements OnInit {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.ncService.getNC(id).subscribe({
|
this.ncService.getNC(id).subscribe({
|
||||||
next: (nc) => {
|
next: (nc) => {
|
||||||
|
this.nc.set(nc);
|
||||||
this.ncForm.patchValue({
|
this.ncForm.patchValue({
|
||||||
level: nc.level,
|
level: nc.level,
|
||||||
description: nc.description,
|
description: nc.description,
|
||||||
status: nc.status,
|
status: nc.status,
|
||||||
due_date: nc.due_date ? new Date(nc.due_date).toISOString().slice(0, 16) : null,
|
due_date: nc.due_date ? new Date(nc.due_date).toISOString().slice(0, 16) : null,
|
||||||
responsible_person: nc.responsible_person,
|
responsible_person: nc.responsible_person,
|
||||||
|
responsible_email: nc.responsible_email,
|
||||||
|
contractor_id: nc.contractor_id,
|
||||||
nc_type: nc.nc_type,
|
nc_type: nc.nc_type,
|
||||||
impact_description: nc.impact_description,
|
impact_description: nc.impact_description,
|
||||||
closure_description: nc.closure_description
|
closure_description: nc.closure_description
|
||||||
|
|
@ -208,7 +292,7 @@ export class NonConformityFormComponent implements OnInit {
|
||||||
|
|
||||||
// Preview Modal
|
// Preview Modal
|
||||||
openPreview(ev: any) {
|
openPreview(ev: any) {
|
||||||
const baseUrl = 'http://192.168.1.74:8000/';
|
const baseUrl = environment.apiUrl + '/';
|
||||||
this.previewModal.set({
|
this.previewModal.set({
|
||||||
type: ev.media_type || 'image/jpeg',
|
type: ev.media_type || 'image/jpeg',
|
||||||
url: ev.file_path.startsWith('http') ? ev.file_path : baseUrl + ev.file_path,
|
url: ev.file_path.startsWith('http') ? ev.file_path : baseUrl + ev.file_path,
|
||||||
|
|
@ -221,34 +305,62 @@ export class NonConformityFormComponent implements OnInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit() {
|
onSubmit() {
|
||||||
if (this.ncForm.invalid || !this.ncId) return;
|
if (this.ncForm.invalid) return;
|
||||||
|
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
const data = {
|
const data = {
|
||||||
...this.ncForm.value,
|
...this.ncForm.value,
|
||||||
action_checklist: this.checklist()
|
action_checklist: this.checklist(),
|
||||||
|
parent_id: this.parentId
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ncService.updateNC(this.ncId, data).subscribe({
|
if (this.ncId) {
|
||||||
next: () => {
|
this.ncService.updateNC(this.ncId, data).subscribe({
|
||||||
if (this.selectedFiles().length > 0) {
|
next: () => this.handlePostSave(this.ncId!),
|
||||||
const uploads = this.selectedFiles().map(f =>
|
error: () => this.loading.set(false)
|
||||||
this.ncService.uploadEvidence(this.ncId!, f.file, f.description, f.capturedAt)
|
});
|
||||||
);
|
} else {
|
||||||
forkJoin(uploads).subscribe({
|
// Create new NC
|
||||||
next: () => this.afterSave(),
|
// If it's a new NC, we must have an activityId (from query param or parent)
|
||||||
error: () => this.afterSave()
|
const activityId = this.activityId || Number(this.route.snapshot.queryParamMap.get('activityId'));
|
||||||
});
|
if (!activityId) {
|
||||||
} else {
|
alert('Error: No se pudo determinar la actividad relacionada.');
|
||||||
this.afterSave();
|
this.loading.set(false);
|
||||||
}
|
return;
|
||||||
},
|
}
|
||||||
error: () => this.loading.set(false)
|
|
||||||
});
|
this.ncService.createNC({ ...data, activity_id: activityId }).subscribe({
|
||||||
|
next: (res) => this.handlePostSave(res.id),
|
||||||
|
error: () => this.loading.set(false)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePostSave(id: number) {
|
||||||
|
if (this.selectedFiles().length > 0) {
|
||||||
|
const uploads = this.selectedFiles().map(f =>
|
||||||
|
this.ncService.uploadEvidence(id, f.file, f.description, f.capturedAt)
|
||||||
|
);
|
||||||
|
forkJoin(uploads).subscribe({
|
||||||
|
next: () => this.afterSave(),
|
||||||
|
error: () => this.afterSave()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.afterSave();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
afterSave() {
|
afterSave() {
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.router.navigate(['/non-conformities']);
|
this.router.navigate(['/non-conformities']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
generateRelatedNC() {
|
||||||
|
if (!this.ncId) return;
|
||||||
|
this.router.navigate(['/non-conformities/new'], { queryParams: { parentId: this.ncId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
openNcRelated(childId: string) {
|
||||||
|
this.router.navigate(['/non-conformities/edit', childId]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../activity-list/activity-list.scss';
|
@use '../activity-list/activity-list.scss' as *;
|
||||||
|
|
||||||
.nc-container {
|
.nc-container {
|
||||||
@extend .activity-container;
|
@extend .activity-container;
|
||||||
|
|
@ -419,7 +419,7 @@
|
||||||
|
|
||||||
.camera-mini-overlay {
|
.camera-mini-overlay {
|
||||||
background: black;
|
background: black;
|
||||||
border-radius: 16px;
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@
|
||||||
background: var(--bg-main);
|
background: var(--bg-main);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 20px;
|
border-radius: var(--radius-md);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 20px;
|
border-radius: var(--radius-md);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,8 @@
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<img src="logo.png" alt="Logo" style="width: 32px; height: 32px;">
|
<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;">
|
||||||
<div style="font-size: 1.5rem;">
|
<span>Suma</span><span style="color: var(--brand-primary);font-weight: 800;">Q</span>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -41,4 +41,8 @@ export class NonConformityService {
|
||||||
|
|
||||||
return this.http.post<any>(`${this.apiUrl}/${ncId}/upload`, formData);
|
return this.http.post<any>(`${this.apiUrl}/${ncId}/upload`, formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifyResponsible(id: number) {
|
||||||
|
return this.http.post<any>(`${this.apiUrl}/${id}/notify`, {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
export const environment = {
|
export const environment = {
|
||||||
apiUrl: 'http://192.168.1.76:8000'
|
apiUrl: 'http://192.168.1.74:8000'
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
export const environment = {
|
export const environment = {
|
||||||
apiUrl: 'http://192.168.1.76:8000'
|
apiUrl: 'http://192.168.1.74:8000'
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||||
|
|
||||||
|
--radius-sm: 8px;
|
||||||
--radius-md: 12px;
|
--radius-md: 12px;
|
||||||
--radius-lg: 16px;
|
--radius-lg: 16px;
|
||||||
|
|
||||||
|
|
@ -95,7 +96,7 @@ button {
|
||||||
.premium-card {
|
.premium-card {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-sm);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
transition: box-shadow 0.2s, border-color 0.2s;
|
transition: box-shadow 0.2s, border-color 0.2s;
|
||||||
|
|
@ -198,3 +199,151 @@ button {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-1 {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-2 {
|
||||||
|
padding-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(15 23 42 / 18%);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 24px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
max-height: 85vh;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-modal {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 10;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
color: #64748b;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1e293b;
|
||||||
|
transform: rotate(90deg) scale(1.1);
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-media {
|
||||||
|
flex: 1;
|
||||||
|
//background: var(--brand-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
audio {
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
//filter: invert(1) hue-rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 24px;
|
||||||
|
background: white;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #1e293b;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--brand-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(180, 83, 9, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(180, 83, 9, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
.download-link {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue