diff --git a/.gitignore b/.gitignore
index 5f90350..184a29b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ backend/venv/**
backend/alembic/.DS_Store
diseño.md
backend/uploads/**
+backend/.env
diff --git a/backend/.env_sample b/backend/.env_sample
new file mode 100644
index 0000000..6e66afc
--- /dev/null
+++ b/backend/.env_sample
@@ -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
\ No newline at end of file
diff --git a/backend/database.py b/backend/database.py
index b33945b..e3cacec 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -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
diff --git a/backend/init_db.py b/backend/init_db.py
index b8749d5..802bcaa 100644
--- a/backend/init_db.py
+++ b/backend/init_db.py
@@ -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()
diff --git a/backend/main.py b/backend/main.py
index adf7557..8e68500 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -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"):
diff --git a/backend/models.py b/backend/models.py
index a9031d6..4bc59bb 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -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"
diff --git a/backend/routers/guest.py b/backend/routers/guest.py
new file mode 100644
index 0000000..1f0c042
--- /dev/null
+++ b/backend/routers/guest.py
@@ -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
diff --git a/backend/routers/non_conformities.py b/backend/routers/non_conformities.py
index 9e0a24b..6607488 100644
--- a/backend/routers/non_conformities.py
+++ b/backend/routers/non_conformities.py
@@ -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}
diff --git a/backend/schemas.py b/backend/schemas.py
index 3d1a6c6..04b798b 100644
--- a/backend/schemas.py
+++ b/backend/schemas.py
@@ -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
diff --git a/backend/services/email_service.py b/backend/services/email_service.py
new file mode 100644
index 0000000..d17d1a2
--- /dev/null
+++ b/backend/services/email_service.py
@@ -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"""
+
+
+
+
Gestión de No Conformidades
+
Estimado/a responsable,
+
Se le ha asignado una No Conformidad con la siguiente descripción:
+
+ {nc_description}
+
+
Por favor, haga clic en el siguiente enlace para revisar los detalles y registrar las acciones tomadas:
+
+
+ Revisar No Conformidad
+
+
+
Si el botón no funciona, copie y pegue el siguiente enlace en su navegador:
{link}
+
+
Este es un mensaje automático, por favor no responda directamente.
+
+
+
+ """
+
+ 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
diff --git a/backend/supervision.db b/backend/supervision.db
index aca33de..65ad522 100644
Binary files a/backend/supervision.db and b/backend/supervision.db differ
diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts
index 8536001..8cef080 100644
--- a/frontend/src/app/app.routes.ts
+++ b/frontend/src/app/app.routes.ts
@@ -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 },
diff --git a/frontend/src/app/components/activity-calendar/activity-calendar.scss b/frontend/src/app/components/activity-calendar/activity-calendar.scss
index db5a8ed..55756af 100644
--- a/frontend/src/app/components/activity-calendar/activity-calendar.scss
+++ b/frontend/src/app/components/activity-calendar/activity-calendar.scss
@@ -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);
}
}
diff --git a/frontend/src/app/components/activity-form/activity-form.html b/frontend/src/app/components/activity-form/activity-form.html
index d46c3e2..00be82c 100644
--- a/frontend/src/app/components/activity-form/activity-form.html
+++ b/frontend/src/app/components/activity-form/activity-form.html
@@ -103,8 +103,7 @@
-
![]()
+
diff --git a/frontend/src/app/components/activity-form/activity-form.scss b/frontend/src/app/components/activity-form/activity-form.scss
index 7ecaa6a..28d2c32 100644
--- a/frontend/src/app/components/activity-form/activity-form.scss
+++ b/frontend/src/app/components/activity-form/activity-form.scss
@@ -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;
diff --git a/frontend/src/app/components/activity-form/activity-form.ts b/frontend/src/app/components/activity-form/activity-form.ts
index 637e758..5a4ff3e 100644
--- a/frontend/src/app/components/activity-form/activity-form.ts
+++ b/frontend/src/app/components/activity-form/activity-form.ts
@@ -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
([]);
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,
diff --git a/frontend/src/app/components/activity-list/activity-list.scss b/frontend/src/app/components/activity-list/activity-list.scss
index e35073d..f2db6dc 100644
--- a/frontend/src/app/components/activity-list/activity-list.scss
+++ b/frontend/src/app/components/activity-list/activity-list.scss
@@ -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);
diff --git a/frontend/src/app/components/contractor-form/contractor-form.scss b/frontend/src/app/components/contractor-form/contractor-form.scss
index 021c03d..158fa52 100644
--- a/frontend/src/app/components/contractor-form/contractor-form.scss
+++ b/frontend/src/app/components/contractor-form/contractor-form.scss
@@ -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);
}
}
diff --git a/frontend/src/app/components/contractor-list/contractor-list.scss b/frontend/src/app/components/contractor-list/contractor-list.scss
index d044270..754d9c3 100644
--- a/frontend/src/app/components/contractor-list/contractor-list.scss
+++ b/frontend/src/app/components/contractor-list/contractor-list.scss
@@ -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;
diff --git a/frontend/src/app/components/header/header.scss b/frontend/src/app/components/header/header.scss
index a93506d..32a738e 100644
--- a/frontend/src/app/components/header/header.scss
+++ b/frontend/src/app/components/header/header.scss
@@ -80,7 +80,7 @@
align-items: center;
gap: 12px;
padding: 05px 30px;
- background-color: #f0eae6;
+ //background-color: #f0eae6;
border-radius: 0.7rem;
cursor: pointer;
diff --git a/frontend/src/app/components/login/login.ts b/frontend/src/app/components/login/login.ts
index c372a19..3aebf7b 100644
--- a/frontend/src/app/components/login/login.ts
+++ b/frontend/src/app/components/login/login.ts
@@ -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(null);
diff --git a/frontend/src/app/components/nc-guest/nc-guest.html b/frontend/src/app/components/nc-guest/nc-guest.html
new file mode 100644
index 0000000..83c18a5
--- /dev/null
+++ b/frontend/src/app/components/nc-guest/nc-guest.html
@@ -0,0 +1,85 @@
+
+
+

+
+ SumaQ
+
+
+
+
Resolución de No Conformidad
+
+
+
+
+
{{ nc().description }}
+
+
+
+ {{ nc().level }}
+
+
+
+ {{ nc().nc_type }}
+
+
+
+
+
Reporte de Acciones
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0">
+
+
![]()
+
+
+ {{ item.file.name }}
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
{{ ev.description }}
+
+
+
+
+
+
+
+
+
+
+
+
Enlace no válido o expirado
+
\ No newline at end of file
diff --git a/frontend/src/app/components/nc-guest/nc-guest.scss b/frontend/src/app/components/nc-guest/nc-guest.scss
new file mode 100644
index 0000000..523ea20
--- /dev/null
+++ b/frontend/src/app/components/nc-guest/nc-guest.scss
@@ -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;
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/app/components/nc-guest/nc-guest.ts b/frontend/src/app/components/nc-guest/nc-guest.ts
new file mode 100644
index 0000000..af8b415
--- /dev/null
+++ b/frontend/src/app/components/nc-guest/nc-guest.ts
@@ -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(null);
+ loading = signal(false);
+ actionsText = '';
+ hash = '';
+
+ selectedFiles = signal([]); // { 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(`${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.');
+ }
+}
diff --git a/frontend/src/app/components/non-conformity-form/non-conformity-form.html b/frontend/src/app/components/non-conformity-form/non-conformity-form.html
index 56c3262..8efdb0c 100644
--- a/frontend/src/app/components/non-conformity-form/non-conformity-form.html
+++ b/frontend/src/app/components/non-conformity-form/non-conformity-form.html
@@ -9,6 +9,11 @@
+
+
+
+
+
+
diff --git a/frontend/src/app/components/non-conformity-form/non-conformity-form.scss b/frontend/src/app/components/non-conformity-form/non-conformity-form.scss
index 73af928..96155d4 100644
--- a/frontend/src/app/components/non-conformity-form/non-conformity-form.scss
+++ b/frontend/src/app/components/non-conformity-form/non-conformity-form.scss
@@ -227,6 +227,11 @@ textarea {
background: rgba(59, 130, 246, 0.05);
}
+ &.in-checking {
+ color: #6366f1;
+ background: rgba(99, 102, 241, 0.05);
+ }
+
&.re-inspect {
color: #f59e0b;
background: rgba(245, 158, 11, 0.05);
@@ -292,7 +297,7 @@ textarea {
.empty-state {
padding: 16px;
- border-radius: 16px;
+ border-radius: var(--radius-sm);
border: 2px dashed var(--border-color);
}
@@ -302,7 +307,7 @@ textarea {
gap: 16px;
padding: 16px;
background: var(--bg-main);
- border-radius: 16px;
+ border-radius: var(--radius-sm);
margin-bottom: 12px;
transition: transform 0.2s ease;
@@ -369,7 +374,7 @@ textarea {
margin-bottom: 32px;
.evidence-thumb {
- border-radius: 16px;
+ border-radius: var(--radius-sm);
overflow: hidden;
background: white;
border: 1px solid var(--border-color);
@@ -457,7 +462,7 @@ textarea {
gap: 8px;
background: var(--bg-main);
border: 2px dashed var(--border-color);
- border-radius: 20px;
+ border-radius: var(--radius-md);
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s;
@@ -477,15 +482,6 @@ textarea {
}
}
-.preview-modal-overlay {
- backdrop-filter: blur(16px);
-
- .modal-content {
- border-radius: 32px;
- box-shadow: 0 40px 100px -20px rgba(0, 0, 0, 0.5);
- }
-}
-
.camera-overlay {
.camera-actions {
.photo {
@@ -522,4 +518,107 @@ textarea {
border-color: var(--text-muted);
}
}
+}
+
+.input-group {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+
+ .flex-grow {
+ flex: 1;
+ }
+}
+
+.icon-btn {
+ width: 50px;
+ height: 50px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 14px;
+ background: var(--brand-primary);
+ color: white;
+ border: none;
+ cursor: pointer;
+ transition: all 0.2s;
+
+ &:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(180, 83, 9, 0.3);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+}
+
+.related-ncs-card {
+ .related-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ margin-top: 16px;
+ }
+
+ .related-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px;
+ background: var(--bg-main);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: all 0.2s;
+ border: 1px solid transparent;
+
+ &:hover {
+ border-color: var(--brand-primary);
+ background: white;
+ transform: translateX(4px);
+ box-shadow: var(--shadow-sm);
+ }
+
+ .item-info {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ .id {
+ font-size: 0.75rem;
+ font-weight: 800;
+ color: var(--brand-primary);
+ }
+
+ .desc {
+ font-size: 0.95rem;
+ font-weight: 500;
+ color: var(--brand-secondary);
+ }
+ }
+
+ .badge {
+ padding: 4px 12px;
+ border-radius: 99px;
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+
+ &.open {
+ background: rgba(239, 68, 68, 0.1);
+ color: #ef4444;
+ }
+
+ &.in-progress {
+ background: rgba(59, 130, 246, 0.1);
+ color: #3b82f6;
+ }
+
+ &.resolved {
+ background: rgba(16, 185, 129, 0.1);
+ color: #10b981;
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/frontend/src/app/components/non-conformity-form/non-conformity-form.ts b/frontend/src/app/components/non-conformity-form/non-conformity-form.ts
index b95a7ff..9233744 100644
--- a/frontend/src/app/components/non-conformity-form/non-conformity-form.ts
+++ b/frontend/src/app/components/non-conformity-form/non-conformity-form.ts
@@ -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
(null);
ncForm: FormGroup;
// Dynamic collections
checklist = signal([]);
+ contractors = signal([]);
newActionText = '';
existingEvidences = signal([]);
@@ -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]);
+ }
}
diff --git a/frontend/src/app/components/non-conformity-list/non-conformity-list.scss b/frontend/src/app/components/non-conformity-list/non-conformity-list.scss
index e068c18..54009b9 100644
--- a/frontend/src/app/components/non-conformity-list/non-conformity-list.scss
+++ b/frontend/src/app/components/non-conformity-list/non-conformity-list.scss
@@ -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;
diff --git a/frontend/src/app/components/project-form/project-form.scss b/frontend/src/app/components/project-form/project-form.scss
index 702ab75..30ccef7 100644
--- a/frontend/src/app/components/project-form/project-form.scss
+++ b/frontend/src/app/components/project-form/project-form.scss
@@ -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);
diff --git a/frontend/src/app/components/project-list/project-list.scss b/frontend/src/app/components/project-list/project-list.scss
index 13ed9a9..fd6c492 100644
--- a/frontend/src/app/components/project-list/project-list.scss
+++ b/frontend/src/app/components/project-list/project-list.scss
@@ -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 {
diff --git a/frontend/src/app/components/sidebar/sidebar.html b/frontend/src/app/components/sidebar/sidebar.html
index 92970f8..bb9a4f9 100644
--- a/frontend/src/app/components/sidebar/sidebar.html
+++ b/frontend/src/app/components/sidebar/sidebar.html
@@ -2,11 +2,8 @@