Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4afdc36765 | ||
|
|
3920cf14a1 |
@@ -31,6 +31,12 @@ class Settings(BaseSettings):
|
|||||||
# ── CORS ─────────────────────────────────────────────────────────
|
# ── CORS ─────────────────────────────────────────────────────────
|
||||||
CORS_ORIGINS: str = "*"
|
CORS_ORIGINS: str = "*"
|
||||||
|
|
||||||
|
# ── WhatsApp ─────────────────────────────────────────────────────
|
||||||
|
WHATSAPP_PHONE_NUMBER_ID: str = ""
|
||||||
|
WHATSAPP_ACCESS_TOKEN: str = ""
|
||||||
|
WHATSAPP_VERIFY_TOKEN: str = ""
|
||||||
|
META_GRAPH_BASE: str = "https://graph.facebook.com/v18.0"
|
||||||
|
|
||||||
# ── Paths ────────────────────────────────────────────────────────
|
# ── Paths ────────────────────────────────────────────────────────
|
||||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
|
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""WhatsApp log model for mock endpoint."""
|
"""WhatsApp log model for inbound webhook messages."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -14,10 +14,12 @@ class WhatsAppLog(Base):
|
|||||||
__tablename__ = "whatsapp_log"
|
__tablename__ = "whatsapp_log"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
command: Mapped[str] = mapped_column(Text, nullable=False)
|
from_number: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
from_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
message_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
wa_message_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
ticket_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
ticket_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
ticket_number: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||||
received_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
received_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<WhatsAppLog {self.id}: {self.command[:50]}>"
|
return f"<WhatsAppLog {self.id}: from={self.from_number} ticket={self.ticket_number}>"
|
||||||
|
|||||||
+134
-13
@@ -1,44 +1,165 @@
|
|||||||
"""Mock WhatsApp endpoint for testing command parsing."""
|
"""WhatsApp webhook handler — Meta Graph API integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.models.whatsapp_log import WhatsAppLog
|
from app.models.whatsapp_log import WhatsAppLog
|
||||||
from app.schemas.whatsapp import MockWhatsAppLogEntry, MockWhatsAppRequest, MockWhatsAppResponse
|
from app.schemas.whatsapp import (
|
||||||
|
MetaWebhookRequest,
|
||||||
|
MockWhatsAppLogEntry,
|
||||||
|
WebhookVerificationResponse,
|
||||||
|
WhatsAppReplyResponse,
|
||||||
|
)
|
||||||
|
from app.services.ticket import create_ticket
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"])
|
router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"])
|
||||||
|
|
||||||
|
REPLY_TEMPLATE = (
|
||||||
|
"Thank you for contacting Denya OneCare. "
|
||||||
|
"Your ticket number is {ticket_number}. "
|
||||||
|
"We will get back to you soon."
|
||||||
|
)
|
||||||
|
|
||||||
@router.post("/mock", response_model=MockWhatsAppResponse)
|
|
||||||
async def mock_whatsapp(
|
# ── Meta Graph API helpers ──────────────────────────────────────────
|
||||||
body: MockWhatsAppRequest,
|
async def send_whatsapp_reply(
|
||||||
|
to_phone: str,
|
||||||
|
text: str,
|
||||||
|
) -> WhatsAppReplyResponse:
|
||||||
|
"""Send a text message via Meta Graph API."""
|
||||||
|
url = f"{settings.META_GRAPH_BASE}/{settings.WHATSAPP_PHONE_NUMBER_ID}/messages"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {settings.WHATSAPP_ACCESS_TOKEN}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"messaging_product": "whatsapp",
|
||||||
|
"to": to_phone,
|
||||||
|
"type": "text",
|
||||||
|
"text": {"body": text},
|
||||||
|
}
|
||||||
|
# Encode nested dict as JSON string for form data (Meta requirement)
|
||||||
|
data["text"] = '{"body":' + f'"{text}"' + "}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(url, headers=headers, data=data, timeout=15.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
return WhatsAppReplyResponse(success=True, message="Reply sent")
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.error("Failed to send WhatsApp reply: %s", exc)
|
||||||
|
return WhatsAppReplyResponse(success=False, message=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Webhook endpoint ────────────────────────────────────────────────
|
||||||
|
@router.post("/webhook")
|
||||||
|
async def whatsapp_webhook(
|
||||||
|
body: MetaWebhookRequest,
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
) -> MockWhatsAppResponse:
|
hub_verify_token: str | None = Query(None, alias="hub.verify_token"),
|
||||||
"""Accept a mock WhatsApp command and log it."""
|
mode: str | None = Query(None),
|
||||||
|
hub_challenge: str | None = Query(None),
|
||||||
|
) -> dict:
|
||||||
|
"""Handle incoming WhatsApp webhook from Meta."""
|
||||||
|
|
||||||
|
# ── Verification GET request (Meta sends this on webhook setup) ──
|
||||||
|
if mode and hub_challenge:
|
||||||
|
if hub_verify_token != settings.WHATSAPP_VERIFY_TOKEN:
|
||||||
|
return {"error": "Verify token mismatch"}
|
||||||
|
return WebhookVerificationResponse(challenge=hub_challenge).model_dump()
|
||||||
|
|
||||||
|
# ── Process inbound messages ────────────────────────────────────
|
||||||
|
if not body.entry:
|
||||||
|
return {"status": "no entry"}
|
||||||
|
|
||||||
|
for entry in body.entry:
|
||||||
|
if not entry.changes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for change in entry.changes:
|
||||||
|
if not change.message or not change.message.text:
|
||||||
|
logger.info("Non-text message received, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
message_text = change.message.text.text
|
||||||
|
sender = change.message.from_field
|
||||||
|
wa_msg_id = change.message.id or change.id
|
||||||
|
|
||||||
|
# ── Create ticket ──────────────────────────────────────
|
||||||
|
try:
|
||||||
|
ticket = await create_ticket(
|
||||||
|
db,
|
||||||
|
data={
|
||||||
|
"description": message_text,
|
||||||
|
"reporter": sender,
|
||||||
|
"reported_via": "WhatsApp",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to create ticket: %s", exc)
|
||||||
|
# Still log the message even if ticket creation fails
|
||||||
log = WhatsAppLog(
|
log = WhatsAppLog(
|
||||||
command=body.command,
|
from_number=sender,
|
||||||
from_number=body.from_number,
|
message_text=message_text,
|
||||||
ticket_id=body.ticket_id,
|
wa_message_id=wa_msg_id,
|
||||||
|
ticket_id=None,
|
||||||
|
ticket_number=None,
|
||||||
received_at=datetime.now(timezone.utc),
|
received_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
db.add(log)
|
db.add(log)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return MockWhatsAppResponse()
|
return {"status": "ticket creation failed, message logged"}
|
||||||
|
|
||||||
|
# ── Store WhatsApp log ─────────────────────────────────
|
||||||
|
log = WhatsAppLog(
|
||||||
|
from_number=sender,
|
||||||
|
message_text=message_text,
|
||||||
|
wa_message_id=wa_msg_id,
|
||||||
|
ticket_id=ticket.id,
|
||||||
|
ticket_number=ticket.ticket_number,
|
||||||
|
received_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# ── Send auto-reply ────────────────────────────────────
|
||||||
|
reply_text = REPLY_TEMPLATE.format(ticket_number=ticket.ticket_number)
|
||||||
|
reply_result = await send_whatsapp_reply(sender, reply_text)
|
||||||
|
if not reply_result.success:
|
||||||
|
logger.warning(
|
||||||
|
"Auto-reply failed for ticket %s: %s",
|
||||||
|
ticket.ticket_number,
|
||||||
|
reply_result.message,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "processed",
|
||||||
|
"ticket_id": ticket.id,
|
||||||
|
"ticket_number": ticket.ticket_number,
|
||||||
|
"reply_sent": reply_result.success,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"status": "no messages"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Legacy debug endpoint ──────────────────────────────────────────
|
||||||
@router.get("/mock-log", response_model=list[MockWhatsAppLogEntry])
|
@router.get("/mock-log", response_model=list[MockWhatsAppLogEntry])
|
||||||
async def mock_whatsapp_log(
|
async def mock_whatsapp_log(
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> list[MockWhatsAppLogEntry]:
|
) -> list[MockWhatsAppLogEntry]:
|
||||||
"""Return recent mock WhatsApp submissions."""
|
"""Return recent WhatsApp webhook submissions for debugging."""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit)
|
select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit)
|
||||||
)
|
)
|
||||||
|
|||||||
+61
-12
@@ -1,10 +1,69 @@
|
|||||||
"""Pydantic schemas for mock WhatsApp endpoint."""
|
"""Pydantic schemas for Meta WhatsApp webhook."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ── Meta Webhook Verification ────────────────────────────────────────
|
||||||
|
class WebhookVerificationResponse(BaseModel):
|
||||||
|
challenge: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── Inbound Message Parsing ─────────────────────────────────────────
|
||||||
|
class MessageText(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class Message(BaseModel):
|
||||||
|
from_field: str = Field(alias="from")
|
||||||
|
id: str | None = None
|
||||||
|
text: MessageText | None = None
|
||||||
|
type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Contact(BaseModel):
|
||||||
|
wa_id: str | None = None
|
||||||
|
profile: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EntryMessagesItem(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
message: Message | None = None
|
||||||
|
contacts: list[Contact] | None = None
|
||||||
|
timestamp: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Entry(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
changes: list[EntryMessagesItem] | None = None
|
||||||
|
metadata: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MetaWebhookRequest(BaseModel):
|
||||||
|
object: str | None = None
|
||||||
|
entry: list[Entry] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auto-reply ──────────────────────────────────────────────────────
|
||||||
|
class WhatsAppReplyResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── Legacy mock schemas (kept for /mock-log endpoint) ───────────────
|
||||||
|
class MockWhatsAppLogEntry(BaseModel):
|
||||||
|
id: int
|
||||||
|
from_number: str
|
||||||
|
message_text: str
|
||||||
|
wa_message_id: str | None
|
||||||
|
ticket_id: int | None
|
||||||
|
ticket_number: str | None
|
||||||
|
received_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
class MockWhatsAppRequest(BaseModel):
|
class MockWhatsAppRequest(BaseModel):
|
||||||
@@ -16,13 +75,3 @@ class MockWhatsAppRequest(BaseModel):
|
|||||||
class MockWhatsAppResponse(BaseModel):
|
class MockWhatsAppResponse(BaseModel):
|
||||||
status: str = "received"
|
status: str = "received"
|
||||||
message: str = "Command logged successfully"
|
message: str = "Command logged successfully"
|
||||||
|
|
||||||
|
|
||||||
class MockWhatsAppLogEntry(BaseModel):
|
|
||||||
id: int
|
|
||||||
command: str
|
|
||||||
from_number: str | None
|
|
||||||
ticket_id: int | None
|
|
||||||
received_at: datetime
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|||||||
+47
-54
@@ -12,23 +12,17 @@
|
|||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
denya: {
|
denya: {
|
||||||
50: '#e8f0ea',
|
50: '#eff6ff',
|
||||||
100: '#c5d9cb',
|
100: '#dbeafe',
|
||||||
200: '#9ebfaa',
|
200: '#bfdbfe',
|
||||||
300: '#74a589',
|
300: '#93c5fd',
|
||||||
400: '#4d8c69',
|
400: '#60a5fa',
|
||||||
500: '#2d734d',
|
500: '#3b82f6',
|
||||||
600: '#1d5a3a',
|
600: '#2563eb',
|
||||||
700: '#0d2b18',
|
700: '#1d4ed8',
|
||||||
800: '#0a2012',
|
800: '#1e40af',
|
||||||
900: '#07150c',
|
900: '#1e3a8a',
|
||||||
},
|
}
|
||||||
gold: {
|
|
||||||
DEFAULT: '#c8a96e',
|
|
||||||
light: '#e8d5a8',
|
|
||||||
dark: '#a88a4e',
|
|
||||||
},
|
|
||||||
cream: '#faf8f5',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,54 +49,49 @@
|
|||||||
.priority-high { @apply bg-orange-100 text-orange-800 border-orange-300; }
|
.priority-high { @apply bg-orange-100 text-orange-800 border-orange-300; }
|
||||||
.priority-medium { @apply bg-yellow-100 text-yellow-800 border-yellow-300; }
|
.priority-medium { @apply bg-yellow-100 text-yellow-800 border-yellow-300; }
|
||||||
.priority-low { @apply bg-green-100 text-green-800 border-green-300; }
|
.priority-low { @apply bg-green-100 text-green-800 border-green-300; }
|
||||||
.brand-gradient { background: linear-gradient(135deg, #0d2b18 0%, #1a3d24 100%); }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-cream min-h-screen text-[#1a1a1a]" x-data="app()" x-init="init()">
|
<body class="bg-gray-50 min-h-screen" x-data="app()" x-init="init()">
|
||||||
<!-- Nav Bar -->
|
<!-- Nav Bar -->
|
||||||
<nav class="bg-[#0d2b18] border-b border-denya-800 shadow-lg sticky top-0 z-50" x-show="isLoggedIn" x-cloak>
|
<nav class="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-50" x-show="isLoggedIn" x-cloak>
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex items-center justify-between h-16">
|
<div class="flex items-center justify-between h-16">
|
||||||
<!-- Left side -->
|
<!-- Left side -->
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<a href="/dashboard/cs" class="flex items-center space-x-3">
|
<a href="/dashboard/cs" class="flex items-center space-x-2 text-denya-700 font-bold text-lg">
|
||||||
<!-- Denya Developers Logo Mark -->
|
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<div class="w-9 h-9 bg-gold rounded-lg flex items-center justify-center shadow-sm">
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||||
<svg class="w-5 h-5 text-[#0d2b18]" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
<span>Denya OneCare</span>
|
||||||
<div class="flex flex-col">
|
|
||||||
<span class="text-white font-bold text-base leading-tight">Denya Developers</span>
|
|
||||||
<span class="text-gold text-xs leading-tight font-medium">OneCare</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
</a>
|
||||||
<!-- Nav Links -->
|
<!-- CS Links -->
|
||||||
<template x-if="isCS">
|
<template x-if="isCS">
|
||||||
<div class="hidden md:flex space-x-1 ml-6">
|
<div class="hidden md:flex space-x-1 ml-6">
|
||||||
<a href="/dashboard/cs" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/cs' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
|
<a href="/dashboard/cs" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/cs' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a>
|
||||||
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">All Issues</a>
|
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">All Issues</a>
|
||||||
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-denya-800/50 transition-colors">Create Issue</a>
|
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50">Create Issue</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- FM Links -->
|
||||||
<template x-if="isFM">
|
<template x-if="isFM">
|
||||||
<div class="hidden md:flex space-x-1 ml-6">
|
<div class="hidden md:flex space-x-1 ml-6">
|
||||||
<a href="/dashboard/fm" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/fm' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
|
<a href="/dashboard/fm" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/fm' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a>
|
||||||
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">All Issues</a>
|
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">All Issues</a>
|
||||||
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-denya-800/50 transition-colors">Create Issue</a>
|
<a href="/tickets/new" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50">Create Issue</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- CEO/Director Links -->
|
||||||
<template x-if="isExecutive">
|
<template x-if="isExecutive">
|
||||||
<div class="hidden md:flex space-x-1 ml-6">
|
<div class="hidden md:flex space-x-1 ml-6">
|
||||||
<a href="/dashboard/ceo" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Dashboard</a>
|
<a href="/dashboard/ceo" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Dashboard</a>
|
||||||
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors" :class="currentPath.startsWith('/tickets') ? 'bg-denya-800 text-gold' : 'text-gray-300 hover:text-white hover:bg-denya-800/50'">Issues</a>
|
<a href="/tickets" class="px-3 py-2 rounded-md text-sm font-medium" :class="currentPath.startsWith('/tickets') ? 'bg-denya-50 text-denya-700' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'">Issues</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<!-- Right side -->
|
<!-- Right side -->
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<span class="text-sm text-gray-300 hidden md:block" x-text="`${user.full_name} (${user.role})`"></span>
|
<span class="text-sm text-gray-600 hidden md:block" x-text="`${user.full_name} (${user.role})`"></span>
|
||||||
<button @click="logout()" class="px-3 py-1.5 text-sm text-gold hover:text-gold-light hover:bg-denya-800/50 rounded-md transition-colors border border-denya-600">
|
<button @click="logout()" class="px-3 py-1.5 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors">
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,19 +99,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Mobile Nav -->
|
<!-- Mobile Nav (CS) -->
|
||||||
<div class="md:hidden border-b bg-[#0d2b18] border-denya-800" x-show="isLoggedIn" x-cloak>
|
<div class="md:hidden border-b bg-white" x-show="isLoggedIn" x-cloak>
|
||||||
<template x-if="isCS || isFM">
|
<template x-if="isCS || isFM">
|
||||||
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
|
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
|
||||||
<a href="/dashboard/cs" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/cs' ? 'bg-denya-800 text-gold' : 'text-gray-300'">Dashboard</a>
|
<a href="/dashboard/cs" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/cs' ? 'bg-denya-50 text-denya-700' : 'text-gray-500'" x-text="isCS ? 'CS Dashboard' : 'FM Dashboard'"></a>
|
||||||
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-800 text-gold' : 'text-gray-300'">Issues</a>
|
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath.startsWith('/tickets') && !currentPath.endsWith('/new') ? 'bg-denya-50 text-denya-700' : 'text-gray-500'">Issues</a>
|
||||||
<a href="/tickets/new" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">New Issue</a>
|
<a href="/tickets/new" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-500">New Issue</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="isExecutive">
|
<template x-if="isExecutive">
|
||||||
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
|
<div class="flex overflow-x-auto px-4 py-2 space-x-2">
|
||||||
<a href="/dashboard/ceo" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-800 text-gold' : 'text-gray-300'">CEO Dashboard</a>
|
<a href="/dashboard/ceo" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap" :class="currentPath === '/dashboard/ceo' ? 'bg-denya-50 text-denya-700' : 'text-gray-500'">CEO Dashboard</a>
|
||||||
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-300">Issues</a>
|
<a href="/tickets" class="px-3 py-1.5 rounded text-sm font-medium whitespace-nowrap text-gray-500">Issues</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,13 +122,13 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Loading Overlay -->
|
<!-- Loading Overlay -->
|
||||||
<div x-show="loading" class="fixed inset-0 bg-[#0d2b18]/40 z-50 flex items-center justify-center" x-cloak>
|
<div x-show="loading" class="fixed inset-0 bg-black bg-opacity-30 z-50 flex items-center justify-center" x-cloak>
|
||||||
<div class="bg-white rounded-xl p-6 flex items-center space-x-3 shadow-2xl border border-denya-200">
|
<div class="bg-white rounded-lg p-6 flex items-center space-x-3 shadow-xl">
|
||||||
<svg class="animate-spin h-6 w-6 text-denya-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg class="animate-spin h-6 w-6 text-denya-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-denya-800 font-medium" x-text="loadingMessage || 'Loading...'"></span>
|
<span class="text-gray-700 font-medium" x-text="loadingMessage || 'Loading...'"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -147,7 +136,7 @@
|
|||||||
<div class="fixed bottom-4 right-4 z-50 space-y-2">
|
<div class="fixed bottom-4 right-4 z-50 space-y-2">
|
||||||
<template x-for="toast in toasts" :key="toast.id">
|
<template x-for="toast in toasts" :key="toast.id">
|
||||||
<div class="px-4 py-3 rounded-lg shadow-lg text-white text-sm font-medium transition-all duration-300"
|
<div class="px-4 py-3 rounded-lg shadow-lg text-white text-sm font-medium transition-all duration-300"
|
||||||
:class="{'bg-denya-600': toast.type === 'success', 'bg-red-600': toast.type === 'error', 'bg-denya-500': toast.type === 'info', 'bg-gold': toast.type === 'warning'}"
|
:class="{'bg-green-600': toast.type === 'success', 'bg-red-600': toast.type === 'error', 'bg-blue-600': toast.type === 'info', 'bg-yellow-600': toast.type === 'warning'}"
|
||||||
x-init="setTimeout(() => { toasts = toasts.filter(t => t.id !== toast.id) }, toast.duration || 4000)">
|
x-init="setTimeout(() => { toasts = toasts.filter(t => t.id !== toast.id) }, toast.duration || 4000)">
|
||||||
<span x-text="toast.message"></span>
|
<span x-text="toast.message"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,10 +164,12 @@
|
|||||||
get isAdmin() { return ['Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
|
get isAdmin() { return ['Admin/Jerome', 'Admin/Wahab'].includes(this.user.role) },
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
// Redirect to login if not logged in (skip for login page)
|
||||||
if (!this.isLoggedIn && this.currentPath !== '/login') {
|
if (!this.isLoggedIn && this.currentPath !== '/login') {
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Verify token on load
|
||||||
if (this.isLoggedIn) {
|
if (this.isLoggedIn) {
|
||||||
this.fetchMe();
|
this.fetchMe();
|
||||||
}
|
}
|
||||||
@@ -218,7 +209,7 @@
|
|||||||
opts.body = JSON.stringify(body);
|
opts.body = JSON.stringify(body);
|
||||||
} else if (body instanceof FormData) {
|
} else if (body instanceof FormData) {
|
||||||
opts.body = body;
|
opts.body = body;
|
||||||
delete opts.headers['Content-Type'];
|
delete opts.headers['Content-Type']; // Let browser set multipart boundary
|
||||||
opts.headers = { 'Authorization': `Bearer ${this.token}` };
|
opts.headers = { 'Authorization': `Bearer ${this.token}` };
|
||||||
}
|
}
|
||||||
const res = await fetch(url, opts);
|
const res = await fetch(url, opts);
|
||||||
@@ -262,11 +253,13 @@
|
|||||||
localStorage.setItem('refresh_token', data.refresh_token);
|
localStorage.setItem('refresh_token', data.refresh_token);
|
||||||
this.token = data.access_token;
|
this.token = data.access_token;
|
||||||
|
|
||||||
|
// Fetch user info
|
||||||
const me = await this.apiGet('/api/auth/me');
|
const me = await this.apiGet('/api/auth/me');
|
||||||
this.user = me;
|
this.user = me;
|
||||||
localStorage.setItem('user', JSON.stringify(me));
|
localStorage.setItem('user', JSON.stringify(me));
|
||||||
this.isLoggedIn = true;
|
this.isLoggedIn = true;
|
||||||
|
|
||||||
|
// Role-based redirect
|
||||||
const role = me.role;
|
const role = me.role;
|
||||||
if (['CS Rep', 'CS Manager'].includes(role)) {
|
if (['CS Rep', 'CS Manager'].includes(role)) {
|
||||||
window.location.href = '/dashboard/cs';
|
window.location.href = '/dashboard/cs';
|
||||||
|
|||||||
Reference in New Issue
Block a user