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
diseño.md
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()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
with SessionLocal() as session:
yield session

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -80,7 +80,7 @@
align-items: center;
gap: 12px;
padding: 05px 30px;
background-color: #f0eae6;
//background-color: #f0eae6;
border-radius: 0.7rem;
cursor: pointer;

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,3 @@
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 = {
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-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;
@ -198,3 +199,151 @@ button {
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;
}
}
}
}
}