No conformidades relacionadas
This commit is contained in:
parent
e2b9b85a40
commit
0d78fb9ccb
|
|
@ -4,3 +4,4 @@ backend/venv/**
|
|||
backend/alembic/.DS_Store
|
||||
diseño.md
|
||||
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()
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
with SessionLocal() as session:
|
||||
yield session
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ def seed_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)
|
||||
admin = User(email="admin@sumaq.com", hashed_password=hashed, full_name="Admin User", role=UserRole.ADMIN)
|
||||
supervisor = User(email="super@sumaq.com", hashed_password=hashed, full_name="Juan Perez", role=UserRole.SUPERVISOR)
|
||||
db.add(admin)
|
||||
db.add(supervisor)
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ if sys.version_info < (3, 10):
|
|||
|
||||
from fastapi import FastAPI
|
||||
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
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
|
|
@ -34,6 +34,7 @@ app.include_router(specialties.router)
|
|||
app.include_router(contractors.router)
|
||||
app.include_router(transcription.router)
|
||||
app.include_router(non_conformities.router)
|
||||
app.include_router(guest.router)
|
||||
|
||||
# Mount uploads directory to serve files
|
||||
if not os.path.exists("uploads"):
|
||||
|
|
|
|||
|
|
@ -138,14 +138,24 @@ class NonConformity(Base):
|
|||
|
||||
# New Fields
|
||||
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}
|
||||
nc_type = Column(Enum(NCType), nullable=True)
|
||||
impact_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")
|
||||
contractor = relationship("Contractor")
|
||||
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):
|
||||
__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 typing import List, Optional
|
||||
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
|
||||
import schemas
|
||||
|
||||
|
|
@ -29,6 +29,12 @@ def create_nc(
|
|||
if not db_activity:
|
||||
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.add(db_nc)
|
||||
db.commit()
|
||||
|
|
@ -74,6 +80,14 @@ def update_nc(
|
|||
raise HTTPException(status_code=404, detail="Non-Conformity not found")
|
||||
|
||||
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():
|
||||
setattr(db_nc, key, value)
|
||||
|
||||
|
|
@ -140,3 +154,33 @@ def delete_nc(
|
|||
db.delete(db_nc)
|
||||
db.commit()
|
||||
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"
|
||||
due_date: Optional[datetime] = 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
|
||||
nc_type: Optional[NCType] = None
|
||||
impact_description: Optional[str] = None
|
||||
closure_description: Optional[str] = None
|
||||
guest_actions: Optional[str] = None
|
||||
parent_id: Optional[int] = 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
|
||||
responsible_email: Optional[str] = None
|
||||
contractor_id: Optional[int] = None
|
||||
access_hash: Optional[str] = None
|
||||
action_checklist: Optional[List[dict]] = None
|
||||
nc_type: Optional[NCType] = None
|
||||
impact_description: Optional[str] = None
|
||||
closure_description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
guest_actions: Optional[str] = None
|
||||
|
||||
class NonConformity(NonConformityBase):
|
||||
id: int
|
||||
activity_id: int
|
||||
evidences: List[Evidence] = []
|
||||
child_ncs: List['NonConformity'] = []
|
||||
|
||||
class Config:
|
||||
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 { authGuard } from './guards/auth';
|
||||
|
||||
import { NCGuestComponent } from './components/nc-guest/nc-guest';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: 'login', component: LoginComponent },
|
||||
{ path: 'nc-guest/:hash', component: NCGuestComponent },
|
||||
{
|
||||
path: '',
|
||||
component: LayoutComponent,
|
||||
|
|
@ -27,6 +30,7 @@ export const routes: Routes = [
|
|||
{ path: 'projects/new', component: ProjectFormComponent },
|
||||
{ path: 'projects/edit/:id', component: ProjectFormComponent },
|
||||
{ path: 'non-conformities', component: NonConformityListComponent },
|
||||
{ path: 'non-conformities/new', component: NonConformityFormComponent },
|
||||
{ path: 'non-conformities/edit/:id', component: NonConformityFormComponent },
|
||||
{ path: 'contractors', component: ContractorListComponent },
|
||||
{ path: 'contractors/new', component: ContractorFormComponent },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
.calendar-wrapper {
|
||||
background: var(--bg-surface);
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-color);
|
||||
|
|
@ -73,7 +73,7 @@
|
|||
gap: 2px;
|
||||
background: var(--border-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
|
|
@ -110,7 +110,7 @@
|
|||
@media (max-width: 1024px) {
|
||||
min-height: auto;
|
||||
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-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/')">
|
||||
<img [src]="apiUrl + '/' + 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>
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@
|
|||
margin-bottom: 24px;
|
||||
padding: 20px;
|
||||
background: var(--bg-surface);
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
|
|
@ -352,7 +352,7 @@
|
|||
.evidence-card-editable {
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -477,7 +477,7 @@
|
|||
flex: 1;
|
||||
min-width: 200px;
|
||||
border: 2px dashed #e2e8f0;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -521,7 +521,7 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
|
|
@ -570,7 +570,7 @@
|
|||
.preview-item-expanded {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
|
@ -684,7 +684,7 @@
|
|||
padding: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
|
|
@ -798,7 +798,7 @@
|
|||
margin-top: 24px;
|
||||
padding: 20px;
|
||||
border: 2px dashed var(--brand-primary);
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(180, 83, 9, 0.02);
|
||||
color: var(--brand-primary);
|
||||
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 {
|
||||
0% {
|
||||
opacity: 1;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators, FormsModule }
|
|||
import { ActivityService } from '../../services/activity';
|
||||
import { ProjectService } from '../../services/project';
|
||||
import { TranscriptionService } from '../../services/transcription';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import {
|
||||
LucideAngularModule, ArrowLeft, Upload, X, Shield, Mic, Square, Loader,
|
||||
|
|
@ -71,6 +72,8 @@ export class ActivityFormComponent implements OnInit {
|
|||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
|
||||
apiUrl: String = environment.apiUrl;
|
||||
|
||||
activityForm: FormGroup;
|
||||
projects = signal<any[]>([]);
|
||||
selectedFiles: EvidenceItem[] = [];
|
||||
|
|
@ -240,7 +243,7 @@ export class ActivityFormComponent implements OnInit {
|
|||
}
|
||||
|
||||
openPreview(ev: any) {
|
||||
const baseUrl = 'http://192.168.1.74:8000/';
|
||||
const baseUrl = environment.apiUrl + '/';
|
||||
this.previewModal.set({
|
||||
type: ev.media_type,
|
||||
url: baseUrl + ev.file_path,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
padding: 16px 24px;
|
||||
margin-bottom: 32px;
|
||||
background: var(--bg-surface);
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
.filter-group {
|
||||
|
|
@ -88,7 +88,7 @@
|
|||
|
||||
.kanban-column {
|
||||
background: #f8fafc;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 350px);
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ select {
|
|||
margin-bottom: 24px;
|
||||
background: var(--bg-main);
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
.add-btn {
|
||||
background: white;
|
||||
|
|
@ -244,7 +244,7 @@ select {
|
|||
padding: 16px 20px;
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
|
|
@ -280,7 +280,7 @@ select {
|
|||
padding: 40px;
|
||||
color: var(--text-muted);
|
||||
background: #f8fafc;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 2px dashed var(--border-color);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@
|
|||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@
|
|||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 05px 30px;
|
||||
background-color: #f0eae6;
|
||||
//background-color: #f0eae6;
|
||||
border-radius: 0.7rem;
|
||||
cursor: pointer;
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export class LoginComponent {
|
|||
private authService = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
username = 'admin@fritosfresh.com';
|
||||
username = 'admin@sumaq.com';
|
||||
password = 'secret';
|
||||
loading = signal(false);
|
||||
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 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="submit" form="ncForm" class="gold-button" [disabled]="loading()">
|
||||
<span *ngIf="!loading()">Guardar Cambios</span>
|
||||
|
|
@ -33,6 +38,7 @@
|
|||
<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="in-checking">En Revisión</option>
|
||||
<option value="re-inspect">Re-Inspección</option>
|
||||
<option value="resolved">Resuelto</option>
|
||||
<option value="closed">Cerrado</option>
|
||||
|
|
@ -66,6 +72,18 @@
|
|||
<option *ngFor="let t of ncTypes" [value]="t">{{ t }}</option>
|
||||
</select>
|
||||
</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">
|
||||
<label>Responsable</label>
|
||||
<div class="input-with-icon">
|
||||
|
|
@ -73,6 +91,21 @@
|
|||
<input type="text" formControlName="responsible_person" placeholder="Nombre del responsable">
|
||||
</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">
|
||||
<label>Fecha Límite</label>
|
||||
<div class="input-with-icon">
|
||||
|
|
@ -138,7 +171,7 @@
|
|||
<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">
|
||||
<img [src]="apiUrl + '/'+ ev.file_path">
|
||||
<div class="overlay">
|
||||
<lucide-icon [img]="Maximize2" size="20"></lucide-icon>
|
||||
</div>
|
||||
|
|
@ -177,6 +210,24 @@
|
|||
</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>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -227,6 +227,11 @@ textarea {
|
|||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
&.in-checking {
|
||||
color: #6366f1;
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
}
|
||||
|
||||
&.re-inspect {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.05);
|
||||
|
|
@ -292,7 +297,7 @@ textarea {
|
|||
|
||||
.empty-state {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 2px dashed var(--border-color);
|
||||
}
|
||||
|
||||
|
|
@ -302,7 +307,7 @@ textarea {
|
|||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: var(--bg-main);
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 12px;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
|
|
@ -369,7 +374,7 @@ textarea {
|
|||
margin-bottom: 32px;
|
||||
|
||||
.evidence-thumb {
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
|
|
@ -457,7 +462,7 @@ textarea {
|
|||
gap: 8px;
|
||||
background: var(--bg-main);
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
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-actions {
|
||||
.photo {
|
||||
|
|
@ -522,4 +518,107 @@ textarea {
|
|||
border-color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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 { NonConformityService } from '../../services/non-conformity';
|
||||
import { ActivityService } from '../../services/activity';
|
||||
import { ContractorService } from '../../services/contractor';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import {
|
||||
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';
|
||||
|
||||
@Component({
|
||||
|
|
@ -34,19 +36,28 @@ export class NonConformityFormComponent implements OnInit {
|
|||
readonly Maximize2 = Maximize2;
|
||||
readonly ExternalLink = ExternalLink;
|
||||
readonly RefreshCw = RefreshCw;
|
||||
readonly Send = Send;
|
||||
readonly Building = Building;
|
||||
|
||||
private fb = inject(FormBuilder);
|
||||
private ncService = inject(NonConformityService);
|
||||
private activityService = inject(ActivityService);
|
||||
private contractorService = inject(ContractorService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
|
||||
apiUrl: String = environment.apiUrl;
|
||||
|
||||
ncId: number | null = null;
|
||||
parentId: number | null = null;
|
||||
activityId: number | null = null;
|
||||
loading = signal(false);
|
||||
nc = signal<any>(null);
|
||||
ncForm: FormGroup;
|
||||
|
||||
// Dynamic collections
|
||||
checklist = signal<any[]>([]);
|
||||
contractors = signal<any[]>([]);
|
||||
newActionText = '';
|
||||
|
||||
existingEvidences = signal<any[]>([]);
|
||||
|
|
@ -75,6 +86,8 @@ export class NonConformityFormComponent implements OnInit {
|
|||
status: ['open', Validators.required],
|
||||
due_date: [null],
|
||||
responsible_person: [''],
|
||||
responsible_email: [''],
|
||||
contractor_id: [null],
|
||||
nc_type: [null],
|
||||
impact_description: [''],
|
||||
closure_description: ['']
|
||||
|
|
@ -82,9 +95,77 @@ export class NonConformityFormComponent implements OnInit {
|
|||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.ncId = Number(this.route.snapshot.paramMap.get('id'));
|
||||
if (this.ncId) {
|
||||
this.loadNC(this.ncId);
|
||||
this.route.paramMap.subscribe(params => {
|
||||
this.ncId = Number(params.get('id'));
|
||||
|
||||
// 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.ncService.getNC(id).subscribe({
|
||||
next: (nc) => {
|
||||
this.nc.set(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,
|
||||
responsible_email: nc.responsible_email,
|
||||
contractor_id: nc.contractor_id,
|
||||
nc_type: nc.nc_type,
|
||||
impact_description: nc.impact_description,
|
||||
closure_description: nc.closure_description
|
||||
|
|
@ -208,7 +292,7 @@ export class NonConformityFormComponent implements OnInit {
|
|||
|
||||
// Preview Modal
|
||||
openPreview(ev: any) {
|
||||
const baseUrl = 'http://192.168.1.74:8000/';
|
||||
const baseUrl = environment.apiUrl + '/';
|
||||
this.previewModal.set({
|
||||
type: ev.media_type || 'image/jpeg',
|
||||
url: ev.file_path.startsWith('http') ? ev.file_path : baseUrl + ev.file_path,
|
||||
|
|
@ -221,34 +305,62 @@ export class NonConformityFormComponent implements OnInit {
|
|||
}
|
||||
|
||||
onSubmit() {
|
||||
if (this.ncForm.invalid || !this.ncId) return;
|
||||
if (this.ncForm.invalid) return;
|
||||
|
||||
this.loading.set(true);
|
||||
const data = {
|
||||
...this.ncForm.value,
|
||||
action_checklist: this.checklist()
|
||||
action_checklist: this.checklist(),
|
||||
parent_id: this.parentId
|
||||
};
|
||||
|
||||
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)
|
||||
});
|
||||
if (this.ncId) {
|
||||
this.ncService.updateNC(this.ncId, data).subscribe({
|
||||
next: () => this.handlePostSave(this.ncId!),
|
||||
error: () => this.loading.set(false)
|
||||
});
|
||||
} else {
|
||||
// Create new NC
|
||||
// If it's a new NC, we must have an activityId (from query param or parent)
|
||||
const activityId = this.activityId || Number(this.route.snapshot.queryParamMap.get('activityId'));
|
||||
if (!activityId) {
|
||||
alert('Error: No se pudo determinar la actividad relacionada.');
|
||||
this.loading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
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() {
|
||||
this.loading.set(false);
|
||||
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 {
|
||||
@extend .activity-container;
|
||||
|
|
@ -419,7 +419,7 @@
|
|||
|
||||
.camera-mini-overlay {
|
||||
background: black;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@
|
|||
background: var(--bg-main);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@
|
|||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
text-transform: uppercase;
|
||||
|
||||
&.active {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,8 @@
|
|||
<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 style="font-size: 1.5rem;">
|
||||
<span>Suma</span><span style="color: var(--brand-primary);font-weight: 800;">Q</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -41,4 +41,8 @@ export class NonConformityService {
|
|||
|
||||
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 = {
|
||||
apiUrl: 'http://192.168.1.76:8000'
|
||||
apiUrl: 'http://192.168.1.74:8000'
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
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-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
|
||||
|
|
@ -95,7 +96,7 @@ button {
|
|||
.premium-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
|
|
@ -197,4 +198,152 @@ button {
|
|||
color: var(--brand-secondary);
|
||||
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