No conformidades relacionadas

This commit is contained in:
Luis Sanchez 2025-12-26 16:08:44 -05:00
parent e2b9b85a40
commit 0d78fb9ccb
35 changed files with 1225 additions and 215 deletions

1
.gitignore vendored
View File

@ -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

22
backend/.env_sample Normal file
View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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"):

View File

@ -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"

97
backend/routers/guest.py Normal file
View File

@ -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

View File

@ -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}

View File

@ -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

View File

@ -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.

View File

@ -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 },

View File

@ -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);
} }
} }

View File

@ -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>

View File

@ -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;

View File

@ -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,

View File

@ -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);

View File

@ -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);
} }
} }

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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>

View File

@ -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;
}
}
}
}
}
}

View File

@ -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.');
}
}

View File

@ -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>

View File

@ -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;
}
}
}
}

View File

@ -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,22 +95,93 @@ export class NonConformityFormComponent implements OnInit {
} }
ngOnInit() { ngOnInit() {
this.ncId = Number(this.route.snapshot.paramMap.get('id')); 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) { if (this.ncId) {
this.loadNC(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));
}
});
}
} }
loadNC(id: number) { loadNC(id: number) {
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,19 +305,41 @@ 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
}; };
if (this.ncId) {
this.ncService.updateNC(this.ncId, data).subscribe({ this.ncService.updateNC(this.ncId, data).subscribe({
next: () => { 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) { if (this.selectedFiles().length > 0) {
const uploads = this.selectedFiles().map(f => const uploads = this.selectedFiles().map(f =>
this.ncService.uploadEvidence(this.ncId!, f.file, f.description, f.capturedAt) this.ncService.uploadEvidence(id, f.file, f.description, f.capturedAt)
); );
forkJoin(uploads).subscribe({ forkJoin(uploads).subscribe({
next: () => this.afterSave(), next: () => this.afterSave(),
@ -242,13 +348,19 @@ export class NonConformityFormComponent implements OnInit {
} else { } else {
this.afterSave(); this.afterSave();
} }
},
error: () => this.loading.set(false)
});
} }
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]);
}
} }

View File

@ -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;

View File

@ -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);

View File

@ -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 {

View File

@ -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: bold;">Q</span> <span>Suma</span><span style="color: var(--brand-primary);font-weight: 800;">Q</span>
</div>
<span style="font-size: 0.8rem;font-weight:200;line-height:0.7rem;">smart</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -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`, {});
}
} }

View File

@ -1,3 +1,3 @@
export const environment = { export const environment = {
apiUrl: 'http://192.168.1.76:8000' apiUrl: 'http://192.168.1.74:8000'
}; };

View File

@ -1,3 +1,3 @@
export const environment = { export const environment = {
apiUrl: 'http://192.168.1.76:8000' apiUrl: 'http://192.168.1.74:8000'
}; };

View File

@ -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;
}
}
}
}
}