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

Resolución de No Conformidad

+ +
+
+ +

{{ nc().description }}

+
+
+ + {{ nc().level }} +
+
+ + {{ nc().nc_type }} +
+
+ +
+

Reporte de Acciones

+ +
+ + +
+ +
+ +
+ + +
+ +
+
+ +
+ + {{ 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 @@
+
+ +
+ +
+ + +
+
+
@@ -73,6 +91,21 @@
+ +
+ +
+
+ + +
+ +
+
+
@@ -138,7 +171,7 @@
- +
@@ -177,6 +210,24 @@
+ + +
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 @@