sync: Auto-sync from Mikes-MacBook-Air.local at 2026-03-09 08:14:13
Synced files: - Session logs updated - Latest context and credentials - Command/directive updates Machine: Mikes-MacBook-Air.local Timestamp: 2026-03-09 08:14:13 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -60,7 +60,7 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
|
|||||||
# are written from script.py.mako
|
# are written from script.py.mako
|
||||||
# output_encoding = utf-8
|
# output_encoding = utf-8
|
||||||
|
|
||||||
sqlalchemy.url = mysql+pymysql://claudetools:CT_e8fcd5a3952030a79ed6debae6c954ed@172.16.3.20:3306/claudetools
|
sqlalchemy.url = mysql+pymysql://claudetools:CT_e8fcd5a3952030a79ed6debae6c954ed@172.16.3.30:3306/claudetools
|
||||||
|
|
||||||
|
|
||||||
[post_write_hooks]
|
[post_write_hooks]
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ from api.routers import (
|
|||||||
security_incidents,
|
security_incidents,
|
||||||
bulk_import,
|
bulk_import,
|
||||||
version,
|
version,
|
||||||
|
quotes,
|
||||||
|
admin_quotes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Import middleware
|
# Import middleware
|
||||||
@@ -124,6 +126,10 @@ app.include_router(credential_audit_logs.router, prefix="/api/credential-audit-l
|
|||||||
app.include_router(security_incidents.router, prefix="/api/security-incidents", tags=["Security Incidents"])
|
app.include_router(security_incidents.router, prefix="/api/security-incidents", tags=["Security Incidents"])
|
||||||
app.include_router(bulk_import.router, prefix="/api/bulk-import", tags=["Bulk Import"])
|
app.include_router(bulk_import.router, prefix="/api/bulk-import", tags=["Bulk Import"])
|
||||||
|
|
||||||
|
# Quote Wizard endpoints (public and admin)
|
||||||
|
app.include_router(quotes.router, prefix="/api/quotes", tags=["Quotes"])
|
||||||
|
app.include_router(admin_quotes.router, prefix="/api/admin/quotes", tags=["Admin Quotes"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from api.models.operation_failure import OperationFailure
|
|||||||
from api.models.pending_task import PendingTask
|
from api.models.pending_task import PendingTask
|
||||||
from api.models.problem_solution import ProblemSolution
|
from api.models.problem_solution import ProblemSolution
|
||||||
from api.models.project import Project
|
from api.models.project import Project
|
||||||
|
from api.models.quote import Quote, QuoteActivity, QuoteItem, QuoteNotification
|
||||||
from api.models.schema_migration import SchemaMigration
|
from api.models.schema_migration import SchemaMigration
|
||||||
from api.models.security_incident import SecurityIncident
|
from api.models.security_incident import SecurityIncident
|
||||||
from api.models.service import Service
|
from api.models.service import Service
|
||||||
@@ -72,6 +73,10 @@ __all__ = [
|
|||||||
"PendingTask",
|
"PendingTask",
|
||||||
"ProblemSolution",
|
"ProblemSolution",
|
||||||
"Project",
|
"Project",
|
||||||
|
"Quote",
|
||||||
|
"QuoteActivity",
|
||||||
|
"QuoteItem",
|
||||||
|
"QuoteNotification",
|
||||||
"SchemaMigration",
|
"SchemaMigration",
|
||||||
"SecurityIncident",
|
"SecurityIncident",
|
||||||
"Service",
|
"Service",
|
||||||
|
|||||||
564
api/models/quote.py
Normal file
564
api/models/quote.py
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
"""
|
||||||
|
Quote models for MSP Quote Wizard.
|
||||||
|
|
||||||
|
Models for managing quotes, quote items, activity tracking, and notifications
|
||||||
|
for the public-facing MSP service quote wizard.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from enum import Enum as PyEnum
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
CHAR,
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from .base import Base, TimestampMixin, UUIDMixin
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteStatus(str, PyEnum):
|
||||||
|
"""Status options for quotes."""
|
||||||
|
DRAFT = "draft"
|
||||||
|
SUBMITTED = "submitted"
|
||||||
|
REVIEWING = "reviewing"
|
||||||
|
APPROVED = "approved"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceCategory(str, PyEnum):
|
||||||
|
"""Service category options for quote items."""
|
||||||
|
MANAGED_SERVICES = "managed_services"
|
||||||
|
SECURITY = "security"
|
||||||
|
BACKUP = "backup"
|
||||||
|
CLOUD = "cloud"
|
||||||
|
HARDWARE = "hardware"
|
||||||
|
SOFTWARE = "software"
|
||||||
|
CONSULTING = "consulting"
|
||||||
|
SUPPORT = "support"
|
||||||
|
|
||||||
|
|
||||||
|
class BillingFrequency(str, PyEnum):
|
||||||
|
"""Billing frequency options for quote items."""
|
||||||
|
MONTHLY = "monthly"
|
||||||
|
QUARTERLY = "quarterly"
|
||||||
|
ANNUAL = "annual"
|
||||||
|
ONE_TIME = "one_time"
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationType(str, PyEnum):
|
||||||
|
"""Notification types for quote events."""
|
||||||
|
EMAIL_SENT = "email_sent"
|
||||||
|
SMS_SENT = "sms_sent"
|
||||||
|
ADMIN_ALERT = "admin_alert"
|
||||||
|
REMINDER_SENT = "reminder_sent"
|
||||||
|
|
||||||
|
|
||||||
|
class Quote(Base, UUIDMixin, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Quote model representing a service quote request.
|
||||||
|
|
||||||
|
Stores quote details including contact information, selections,
|
||||||
|
and calculated totals. Uses an access token for public URL access.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
access_token: Unique token for public access (URL-safe, 43 chars)
|
||||||
|
status: Current quote status (draft, submitted, reviewing, etc.)
|
||||||
|
company_name: Prospect company name
|
||||||
|
contact_name: Primary contact name
|
||||||
|
contact_email: Contact email address
|
||||||
|
contact_phone: Contact phone number
|
||||||
|
employee_count: Number of employees/users
|
||||||
|
notes: Customer notes or special requirements
|
||||||
|
admin_notes: Internal admin notes (not visible to customer)
|
||||||
|
monthly_total: Calculated monthly recurring total
|
||||||
|
setup_total: Calculated one-time setup total
|
||||||
|
annual_total: Calculated annual total
|
||||||
|
expires_at: Quote expiration date
|
||||||
|
submitted_at: Timestamp when quote was submitted
|
||||||
|
ip_address: IP address of the requester
|
||||||
|
user_agent: Browser user agent string
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "quotes"
|
||||||
|
|
||||||
|
# Access control
|
||||||
|
access_token: Mapped[str] = mapped_column(
|
||||||
|
String(64),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: secrets.token_urlsafe(32),
|
||||||
|
doc="Unique access token for public URL (URL-safe, 43 chars)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=False,
|
||||||
|
default=QuoteStatus.DRAFT.value,
|
||||||
|
server_default=QuoteStatus.DRAFT.value,
|
||||||
|
doc="Quote status: draft, submitted, reviewing, approved, rejected, expired"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Contact information (optional until submission)
|
||||||
|
company_name: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
doc="Prospect company name"
|
||||||
|
)
|
||||||
|
|
||||||
|
contact_name: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
doc="Primary contact name"
|
||||||
|
)
|
||||||
|
|
||||||
|
contact_email: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
doc="Contact email address"
|
||||||
|
)
|
||||||
|
|
||||||
|
contact_phone: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
doc="Contact phone number"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Business information
|
||||||
|
employee_count: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
doc="Number of employees/users"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notes
|
||||||
|
notes: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text,
|
||||||
|
doc="Customer notes or special requirements"
|
||||||
|
)
|
||||||
|
|
||||||
|
admin_notes: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text,
|
||||||
|
doc="Internal admin notes (not visible to customer)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculated totals
|
||||||
|
monthly_total: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(10, 2),
|
||||||
|
nullable=False,
|
||||||
|
default=Decimal("0.00"),
|
||||||
|
server_default="0.00",
|
||||||
|
doc="Calculated monthly recurring total"
|
||||||
|
)
|
||||||
|
|
||||||
|
setup_total: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(10, 2),
|
||||||
|
nullable=False,
|
||||||
|
default=Decimal("0.00"),
|
||||||
|
server_default="0.00",
|
||||||
|
doc="Calculated one-time setup total"
|
||||||
|
)
|
||||||
|
|
||||||
|
annual_total: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(10, 2),
|
||||||
|
nullable=False,
|
||||||
|
default=Decimal("0.00"),
|
||||||
|
server_default="0.00",
|
||||||
|
doc="Calculated annual total"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
expires_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
doc="Quote expiration date"
|
||||||
|
)
|
||||||
|
|
||||||
|
submitted_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
doc="Timestamp when quote was submitted"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tracking
|
||||||
|
ip_address: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(45),
|
||||||
|
doc="IP address of the requester (IPv4 or IPv6)"
|
||||||
|
)
|
||||||
|
|
||||||
|
user_agent: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
doc="Browser user agent string"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Syncro RMM Integration
|
||||||
|
syncro_lead_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
doc="Lead ID in SyncroRMM if synced"
|
||||||
|
)
|
||||||
|
|
||||||
|
syncro_synced_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
doc="Timestamp when quote was synced to Syncro"
|
||||||
|
)
|
||||||
|
|
||||||
|
is_existing_customer: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
nullable=False,
|
||||||
|
default=False,
|
||||||
|
server_default="0",
|
||||||
|
doc="Whether this is an existing Syncro customer"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
items: Mapped[list["QuoteItem"]] = relationship(
|
||||||
|
"QuoteItem",
|
||||||
|
back_populates="quote",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
doc="Line items in this quote"
|
||||||
|
)
|
||||||
|
|
||||||
|
activities: Mapped[list["QuoteActivity"]] = relationship(
|
||||||
|
"QuoteActivity",
|
||||||
|
back_populates="quote",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="QuoteActivity.created_at.desc()",
|
||||||
|
doc="Activity log for this quote"
|
||||||
|
)
|
||||||
|
|
||||||
|
notifications: Mapped[list["QuoteNotification"]] = relationship(
|
||||||
|
"QuoteNotification",
|
||||||
|
back_populates="quote",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
doc="Notifications sent for this quote"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Constraints and indexes
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('draft', 'submitted', 'reviewing', 'approved', 'rejected', 'expired')",
|
||||||
|
name="ck_quotes_status"
|
||||||
|
),
|
||||||
|
Index("idx_quotes_access_token", "access_token"),
|
||||||
|
Index("idx_quotes_status", "status"),
|
||||||
|
Index("idx_quotes_contact_email", "contact_email"),
|
||||||
|
Index("idx_quotes_created_at", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""String representation of the quote."""
|
||||||
|
return f"<Quote(id='{self.id}', status='{self.status}', company='{self.company_name}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteItem(Base, UUIDMixin, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Quote item model representing a single line item in a quote.
|
||||||
|
|
||||||
|
Stores service details, pricing, and quantity information.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
quote_id: Reference to the parent quote
|
||||||
|
service_name: Name of the service
|
||||||
|
service_description: Detailed description of the service
|
||||||
|
category: Service category (managed_services, security, etc.)
|
||||||
|
billing_frequency: Billing frequency (monthly, annual, one_time)
|
||||||
|
unit_price: Price per unit
|
||||||
|
quantity: Number of units
|
||||||
|
setup_fee: One-time setup fee
|
||||||
|
is_required: Whether this item is required (cannot be removed)
|
||||||
|
sort_order: Display order within the quote
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "quote_items"
|
||||||
|
|
||||||
|
# Foreign keys
|
||||||
|
quote_id: Mapped[str] = mapped_column(
|
||||||
|
CHAR(36),
|
||||||
|
ForeignKey("quotes.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
doc="Reference to the parent quote"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Service identification
|
||||||
|
service_name: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
doc="Name of the service"
|
||||||
|
)
|
||||||
|
|
||||||
|
service_description: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text,
|
||||||
|
doc="Detailed description of the service"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Category
|
||||||
|
category: Mapped[str] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
nullable=False,
|
||||||
|
default=ServiceCategory.MANAGED_SERVICES.value,
|
||||||
|
doc="Service category: managed_services, security, backup, cloud, etc."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Billing
|
||||||
|
billing_frequency: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=False,
|
||||||
|
default=BillingFrequency.MONTHLY.value,
|
||||||
|
doc="Billing frequency: monthly, quarterly, annual, one_time"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pricing
|
||||||
|
unit_price: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(10, 2),
|
||||||
|
nullable=False,
|
||||||
|
default=Decimal("0.00"),
|
||||||
|
doc="Price per unit"
|
||||||
|
)
|
||||||
|
|
||||||
|
quantity: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
default=1,
|
||||||
|
doc="Number of units"
|
||||||
|
)
|
||||||
|
|
||||||
|
setup_fee: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(10, 2),
|
||||||
|
nullable=False,
|
||||||
|
default=Decimal("0.00"),
|
||||||
|
server_default="0.00",
|
||||||
|
doc="One-time setup fee"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
is_required: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
nullable=False,
|
||||||
|
default=False,
|
||||||
|
server_default="0",
|
||||||
|
doc="Whether this item is required (cannot be removed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
sort_order: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
default=0,
|
||||||
|
server_default="0",
|
||||||
|
doc="Display order within the quote"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
quote: Mapped["Quote"] = relationship(
|
||||||
|
"Quote",
|
||||||
|
back_populates="items"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Constraints and indexes
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"category IN ('managed_services', 'security', 'backup', 'cloud', 'hardware', 'software', 'consulting', 'support')",
|
||||||
|
name="ck_quote_items_category"
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"billing_frequency IN ('monthly', 'quarterly', 'annual', 'one_time')",
|
||||||
|
name="ck_quote_items_billing_frequency"
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"quantity >= 1",
|
||||||
|
name="ck_quote_items_quantity_positive"
|
||||||
|
),
|
||||||
|
Index("idx_quote_items_quote_id", "quote_id"),
|
||||||
|
Index("idx_quote_items_category", "category"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""String representation of the quote item."""
|
||||||
|
return f"<QuoteItem(service='{self.service_name}', qty={self.quantity}, price={self.unit_price})>"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def line_total(self) -> Decimal:
|
||||||
|
"""Calculate the line total (unit_price * quantity)."""
|
||||||
|
return self.unit_price * self.quantity
|
||||||
|
|
||||||
|
@property
|
||||||
|
def monthly_amount(self) -> Decimal:
|
||||||
|
"""Calculate the monthly amount based on billing frequency."""
|
||||||
|
if self.billing_frequency == BillingFrequency.MONTHLY.value:
|
||||||
|
return self.line_total
|
||||||
|
elif self.billing_frequency == BillingFrequency.QUARTERLY.value:
|
||||||
|
return self.line_total / Decimal("3")
|
||||||
|
elif self.billing_frequency == BillingFrequency.ANNUAL.value:
|
||||||
|
return self.line_total / Decimal("12")
|
||||||
|
else: # one_time
|
||||||
|
return Decimal("0.00")
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteActivity(Base, UUIDMixin, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Quote activity model for tracking quote history and changes.
|
||||||
|
|
||||||
|
Logs all actions taken on a quote for audit and tracking purposes.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
quote_id: Reference to the parent quote
|
||||||
|
action: Action performed (created, updated, submitted, etc.)
|
||||||
|
description: Detailed description of the action
|
||||||
|
actor: Who performed the action (email, 'system', 'admin')
|
||||||
|
ip_address: IP address of the actor
|
||||||
|
metadata: JSON metadata about the action
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "quote_activities"
|
||||||
|
|
||||||
|
# Foreign keys
|
||||||
|
quote_id: Mapped[str] = mapped_column(
|
||||||
|
CHAR(36),
|
||||||
|
ForeignKey("quotes.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
doc="Reference to the parent quote"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Activity details
|
||||||
|
action: Mapped[str] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
nullable=False,
|
||||||
|
doc="Action performed: created, updated, item_added, item_removed, submitted, status_changed, etc."
|
||||||
|
)
|
||||||
|
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text,
|
||||||
|
doc="Detailed description of the action"
|
||||||
|
)
|
||||||
|
|
||||||
|
actor: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
doc="Who performed the action (email, 'system', 'admin')"
|
||||||
|
)
|
||||||
|
|
||||||
|
ip_address: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(45),
|
||||||
|
doc="IP address of the actor"
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text,
|
||||||
|
doc="JSON metadata about the action"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
quote: Mapped["Quote"] = relationship(
|
||||||
|
"Quote",
|
||||||
|
back_populates="activities"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indexes
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_quote_activities_quote_id", "quote_id"),
|
||||||
|
Index("idx_quote_activities_action", "action"),
|
||||||
|
Index("idx_quote_activities_created_at", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""String representation of the quote activity."""
|
||||||
|
return f"<QuoteActivity(quote_id='{self.quote_id}', action='{self.action}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteNotification(Base, UUIDMixin, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Quote notification model for tracking notifications sent.
|
||||||
|
|
||||||
|
Records all notifications (emails, SMS, alerts) sent for a quote.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
quote_id: Reference to the parent quote
|
||||||
|
notification_type: Type of notification (email_sent, sms_sent, etc.)
|
||||||
|
recipient: Notification recipient (email, phone, etc.)
|
||||||
|
subject: Notification subject
|
||||||
|
content: Notification content/body
|
||||||
|
status: Delivery status (pending, sent, delivered, failed)
|
||||||
|
sent_at: Timestamp when notification was sent
|
||||||
|
error_message: Error message if delivery failed
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "quote_notifications"
|
||||||
|
|
||||||
|
# Foreign keys
|
||||||
|
quote_id: Mapped[str] = mapped_column(
|
||||||
|
CHAR(36),
|
||||||
|
ForeignKey("quotes.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
doc="Reference to the parent quote"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notification details
|
||||||
|
notification_type: Mapped[str] = mapped_column(
|
||||||
|
String(30),
|
||||||
|
nullable=False,
|
||||||
|
doc="Type of notification: email_sent, sms_sent, admin_alert, reminder_sent"
|
||||||
|
)
|
||||||
|
|
||||||
|
recipient: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
doc="Notification recipient (email, phone, etc.)"
|
||||||
|
)
|
||||||
|
|
||||||
|
subject: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
doc="Notification subject"
|
||||||
|
)
|
||||||
|
|
||||||
|
content: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text,
|
||||||
|
doc="Notification content/body"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status tracking
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=False,
|
||||||
|
default="pending",
|
||||||
|
server_default="pending",
|
||||||
|
doc="Delivery status: pending, sent, delivered, failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
sent_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
doc="Timestamp when notification was sent"
|
||||||
|
)
|
||||||
|
|
||||||
|
error_message: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text,
|
||||||
|
doc="Error message if delivery failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
quote: Mapped["Quote"] = relationship(
|
||||||
|
"Quote",
|
||||||
|
back_populates="notifications"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Constraints and indexes
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"notification_type IN ('email_sent', 'sms_sent', 'admin_alert', 'reminder_sent')",
|
||||||
|
name="ck_quote_notifications_type"
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('pending', 'sent', 'delivered', 'failed')",
|
||||||
|
name="ck_quote_notifications_status"
|
||||||
|
),
|
||||||
|
Index("idx_quote_notifications_quote_id", "quote_id"),
|
||||||
|
Index("idx_quote_notifications_type", "notification_type"),
|
||||||
|
Index("idx_quote_notifications_status", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""String representation of the quote notification."""
|
||||||
|
return f"<QuoteNotification(type='{self.notification_type}', recipient='{self.recipient}', status='{self.status}')>"
|
||||||
384
api/routers/admin_quotes.py
Normal file
384
api/routers/admin_quotes.py
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
"""
|
||||||
|
Admin Quote API router for ClaudeTools.
|
||||||
|
|
||||||
|
This module defines all admin REST API endpoints for managing quotes,
|
||||||
|
requiring JWT authentication for access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from api.database import get_db
|
||||||
|
from api.middleware.auth import get_current_user
|
||||||
|
from api.schemas.quote import (
|
||||||
|
QuoteAdminResponse,
|
||||||
|
QuoteAdminUpdate,
|
||||||
|
QuoteActivityResponse,
|
||||||
|
QuoteItemResponse,
|
||||||
|
QuoteListItem,
|
||||||
|
QuoteListResponse,
|
||||||
|
QuoteNotificationResponse,
|
||||||
|
QuoteStatsResponse,
|
||||||
|
QuoteStatus,
|
||||||
|
)
|
||||||
|
from api.services import quote_service
|
||||||
|
|
||||||
|
# Create router with authentication required
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=QuoteListResponse,
|
||||||
|
summary="List all quotes",
|
||||||
|
description="Retrieve a paginated list of all quotes with optional filtering",
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
def list_quotes(
|
||||||
|
skip: int = Query(
|
||||||
|
default=0,
|
||||||
|
ge=0,
|
||||||
|
description="Number of records to skip for pagination"
|
||||||
|
),
|
||||||
|
limit: int = Query(
|
||||||
|
default=100,
|
||||||
|
ge=1,
|
||||||
|
le=1000,
|
||||||
|
description="Maximum number of records to return (max 1000)"
|
||||||
|
),
|
||||||
|
status_filter: Optional[str] = Query(
|
||||||
|
default=None,
|
||||||
|
alias="status",
|
||||||
|
description="Filter by status (draft, submitted, reviewing, approved, rejected, expired)"
|
||||||
|
),
|
||||||
|
search: Optional[str] = Query(
|
||||||
|
default=None,
|
||||||
|
description="Search in company_name, contact_name, contact_email"
|
||||||
|
),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List all quotes with pagination and filtering.
|
||||||
|
|
||||||
|
- **skip**: Number of quotes to skip (default: 0)
|
||||||
|
- **limit**: Maximum number of quotes to return (default: 100, max: 1000)
|
||||||
|
- **status**: Filter by quote status
|
||||||
|
- **search**: Search in company name, contact name, or email
|
||||||
|
|
||||||
|
Returns a list of quotes with pagination metadata.
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```
|
||||||
|
GET /api/admin/quotes?skip=0&limit=50&status=submitted
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": 25,
|
||||||
|
"skip": 0,
|
||||||
|
"limit": 50,
|
||||||
|
"quotes": [
|
||||||
|
{
|
||||||
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||||
|
"access_token": "xYz123...",
|
||||||
|
"status": "submitted",
|
||||||
|
"company_name": "Acme Corporation",
|
||||||
|
"contact_name": "John Doe",
|
||||||
|
"contact_email": "john@acme.com",
|
||||||
|
"employee_count": 25,
|
||||||
|
"monthly_total": "450.00",
|
||||||
|
"setup_total": "500.00",
|
||||||
|
"item_count": 3,
|
||||||
|
"submitted_at": "2024-01-15T14:30:00Z",
|
||||||
|
"created_at": "2024-01-15T10:30:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
quotes, total = quote_service.list_quotes(
|
||||||
|
db=db,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
status_filter=status_filter,
|
||||||
|
search=search
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build list items with item counts
|
||||||
|
quote_items = []
|
||||||
|
for quote in quotes:
|
||||||
|
quote_items.append(QuoteListItem(
|
||||||
|
id=quote.id,
|
||||||
|
access_token=quote.access_token,
|
||||||
|
status=quote.status,
|
||||||
|
company_name=quote.company_name,
|
||||||
|
contact_name=quote.contact_name,
|
||||||
|
contact_email=quote.contact_email,
|
||||||
|
employee_count=quote.employee_count,
|
||||||
|
monthly_total=quote.monthly_total,
|
||||||
|
setup_total=quote.setup_total,
|
||||||
|
item_count=len(quote.items),
|
||||||
|
submitted_at=quote.submitted_at,
|
||||||
|
created_at=quote.created_at
|
||||||
|
))
|
||||||
|
|
||||||
|
return QuoteListResponse(
|
||||||
|
total=total,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
quotes=quote_items
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/stats",
|
||||||
|
response_model=QuoteStatsResponse,
|
||||||
|
summary="Get quote statistics",
|
||||||
|
description="Get dashboard statistics for quotes",
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
def get_stats(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get quote statistics for the admin dashboard.
|
||||||
|
|
||||||
|
Returns aggregate statistics including totals, counts by status,
|
||||||
|
and conversion rates.
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```
|
||||||
|
GET /api/admin/quotes/stats
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_quotes": 150,
|
||||||
|
"quotes_by_status": {
|
||||||
|
"draft": 45,
|
||||||
|
"submitted": 60,
|
||||||
|
"reviewing": 15,
|
||||||
|
"approved": 25,
|
||||||
|
"rejected": 3,
|
||||||
|
"expired": 2
|
||||||
|
},
|
||||||
|
"total_monthly_value": "12500.00",
|
||||||
|
"total_setup_value": "8500.00",
|
||||||
|
"quotes_this_month": 28,
|
||||||
|
"quotes_submitted_this_month": 18,
|
||||||
|
"average_monthly_value": "125.00",
|
||||||
|
"conversion_rate": "66.67"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
return quote_service.get_quote_stats(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{quote_id}",
|
||||||
|
response_model=QuoteAdminResponse,
|
||||||
|
summary="Get quote by ID",
|
||||||
|
description="Retrieve a single quote by its ID with full details",
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "Quote found and returned",
|
||||||
|
"model": QuoteAdminResponse,
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
"description": "Quote not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {"detail": "Quote with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def get_quote(
|
||||||
|
quote_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get a specific quote by ID with full admin details.
|
||||||
|
|
||||||
|
Returns the quote with all items, activities, and notifications.
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```
|
||||||
|
GET /api/admin/quotes/123e4567-e89b-12d3-a456-426614174000
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||||
|
"access_token": "xYz123...",
|
||||||
|
"status": "submitted",
|
||||||
|
"company_name": "Acme Corporation",
|
||||||
|
"contact_name": "John Doe",
|
||||||
|
"contact_email": "john@acme.com",
|
||||||
|
"admin_notes": "Follow up scheduled for next week",
|
||||||
|
"ip_address": "192.168.1.100",
|
||||||
|
"user_agent": "Mozilla/5.0...",
|
||||||
|
"items": [...],
|
||||||
|
"activities": [
|
||||||
|
{
|
||||||
|
"id": "789...",
|
||||||
|
"action": "submitted",
|
||||||
|
"description": "Quote submitted by John Doe (john@acme.com)",
|
||||||
|
"actor": "john@acme.com",
|
||||||
|
"created_at": "2024-01-15T14:30:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notifications": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
quote = quote_service.get_quote_by_id(db, quote_id)
|
||||||
|
|
||||||
|
# Build response with all related data
|
||||||
|
items_response = []
|
||||||
|
for item in quote.items:
|
||||||
|
items_response.append(QuoteItemResponse(
|
||||||
|
id=item.id,
|
||||||
|
quote_id=item.quote_id,
|
||||||
|
service_name=item.service_name,
|
||||||
|
service_description=item.service_description,
|
||||||
|
category=item.category,
|
||||||
|
billing_frequency=item.billing_frequency,
|
||||||
|
unit_price=item.unit_price,
|
||||||
|
quantity=item.quantity,
|
||||||
|
setup_fee=item.setup_fee,
|
||||||
|
is_required=item.is_required,
|
||||||
|
sort_order=item.sort_order,
|
||||||
|
line_total=item.line_total,
|
||||||
|
monthly_amount=item.monthly_amount,
|
||||||
|
created_at=item.created_at,
|
||||||
|
updated_at=item.updated_at
|
||||||
|
))
|
||||||
|
|
||||||
|
activities_response = []
|
||||||
|
for activity in quote.activities:
|
||||||
|
activities_response.append(QuoteActivityResponse(
|
||||||
|
id=activity.id,
|
||||||
|
quote_id=activity.quote_id,
|
||||||
|
action=activity.action,
|
||||||
|
description=activity.description,
|
||||||
|
actor=activity.actor,
|
||||||
|
ip_address=activity.ip_address,
|
||||||
|
created_at=activity.created_at
|
||||||
|
))
|
||||||
|
|
||||||
|
notifications_response = []
|
||||||
|
for notification in quote.notifications:
|
||||||
|
notifications_response.append(QuoteNotificationResponse(
|
||||||
|
id=notification.id,
|
||||||
|
quote_id=notification.quote_id,
|
||||||
|
notification_type=notification.notification_type,
|
||||||
|
recipient=notification.recipient,
|
||||||
|
subject=notification.subject,
|
||||||
|
status=notification.status,
|
||||||
|
sent_at=notification.sent_at,
|
||||||
|
error_message=notification.error_message,
|
||||||
|
created_at=notification.created_at
|
||||||
|
))
|
||||||
|
|
||||||
|
return QuoteAdminResponse(
|
||||||
|
id=quote.id,
|
||||||
|
access_token=quote.access_token,
|
||||||
|
status=quote.status,
|
||||||
|
company_name=quote.company_name,
|
||||||
|
contact_name=quote.contact_name,
|
||||||
|
contact_email=quote.contact_email,
|
||||||
|
contact_phone=quote.contact_phone,
|
||||||
|
employee_count=quote.employee_count,
|
||||||
|
notes=quote.notes,
|
||||||
|
admin_notes=quote.admin_notes,
|
||||||
|
monthly_total=quote.monthly_total,
|
||||||
|
setup_total=quote.setup_total,
|
||||||
|
annual_total=quote.annual_total,
|
||||||
|
expires_at=quote.expires_at,
|
||||||
|
submitted_at=quote.submitted_at,
|
||||||
|
ip_address=quote.ip_address,
|
||||||
|
user_agent=quote.user_agent,
|
||||||
|
created_at=quote.created_at,
|
||||||
|
updated_at=quote.updated_at,
|
||||||
|
items=items_response,
|
||||||
|
activities=activities_response,
|
||||||
|
notifications=notifications_response
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{quote_id}",
|
||||||
|
response_model=QuoteAdminResponse,
|
||||||
|
summary="Update quote status/notes",
|
||||||
|
description="Update a quote's status or admin notes",
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "Quote updated successfully",
|
||||||
|
"model": QuoteAdminResponse,
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
"description": "Quote not found",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def update_quote(
|
||||||
|
quote_id: UUID,
|
||||||
|
update_data: QuoteAdminUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update a quote's status or admin notes.
|
||||||
|
|
||||||
|
Admins can change the quote status (e.g., from submitted to reviewing
|
||||||
|
or approved) and add internal notes.
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```json
|
||||||
|
PUT /api/admin/quotes/123e4567-e89b-12d3-a456-426614174000
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"status": "reviewing",
|
||||||
|
"admin_notes": "Assigned to sales team. Follow up scheduled for Monday."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||||
|
"status": "reviewing",
|
||||||
|
"admin_notes": "Assigned to sales team. Follow up scheduled for Monday.",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
# Get admin username from token
|
||||||
|
admin_user = current_user.get("sub", "admin")
|
||||||
|
|
||||||
|
quote_service.update_quote_status(
|
||||||
|
db=db,
|
||||||
|
quote_id=quote_id,
|
||||||
|
update_data=update_data,
|
||||||
|
admin_user=admin_user
|
||||||
|
)
|
||||||
|
|
||||||
|
return get_quote(quote_id, db, current_user)
|
||||||
519
api/routers/quotes.py
Normal file
519
api/routers/quotes.py
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
"""
|
||||||
|
Public Quote API router for ClaudeTools.
|
||||||
|
|
||||||
|
This module defines all public REST API endpoints for the MSP Quote Wizard,
|
||||||
|
allowing prospects to create, view, and submit quotes without authentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from api.database import get_db
|
||||||
|
from api.schemas.quote import (
|
||||||
|
QuoteCreate,
|
||||||
|
QuoteCreatedResponse,
|
||||||
|
QuoteItemCreate,
|
||||||
|
QuoteResponse,
|
||||||
|
QuoteItemResponse,
|
||||||
|
QuoteSubmit,
|
||||||
|
QuoteUpdate,
|
||||||
|
)
|
||||||
|
from api.services import quote_service
|
||||||
|
|
||||||
|
# Create router (no authentication required for public endpoints)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_ip(request: Request) -> Optional[str]:
|
||||||
|
"""Extract client IP from request, handling proxies."""
|
||||||
|
forwarded = request.headers.get("X-Forwarded-For")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_agent(request: Request) -> Optional[str]:
|
||||||
|
"""Extract user agent from request."""
|
||||||
|
return request.headers.get("User-Agent")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=QuoteCreatedResponse,
|
||||||
|
summary="Create new quote draft",
|
||||||
|
description="Create a new quote draft. Returns an access token for future access.",
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
responses={
|
||||||
|
201: {
|
||||||
|
"description": "Quote created successfully",
|
||||||
|
"model": QuoteCreatedResponse,
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
"description": "Server error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {"detail": "Failed to create quote"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def create_quote(
|
||||||
|
quote_data: QuoteCreate,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new quote draft.
|
||||||
|
|
||||||
|
This endpoint does not require authentication. A unique access token is
|
||||||
|
generated for the quote which can be used to access it later.
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```json
|
||||||
|
POST /api/quotes
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"employee_count": 25,
|
||||||
|
"notes": "Looking for complete managed services package"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||||
|
"access_token": "xYz123abc456def789ghi012jkl345mno678pqr901stu",
|
||||||
|
"status": "draft",
|
||||||
|
"message": "Quote created successfully. Use the access_token to access your quote."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
ip_address = get_client_ip(request)
|
||||||
|
user_agent = get_user_agent(request)
|
||||||
|
|
||||||
|
quote = quote_service.create_quote(
|
||||||
|
db=db,
|
||||||
|
quote_data=quote_data,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent
|
||||||
|
)
|
||||||
|
|
||||||
|
return QuoteCreatedResponse(
|
||||||
|
id=quote.id,
|
||||||
|
access_token=quote.access_token,
|
||||||
|
status=quote.status,
|
||||||
|
message="Quote created successfully. Use the access_token to access your quote."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{access_token}",
|
||||||
|
response_model=QuoteResponse,
|
||||||
|
summary="Get quote by access token",
|
||||||
|
description="Retrieve a quote by its access token",
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "Quote found and returned",
|
||||||
|
"model": QuoteResponse,
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
"description": "Quote not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {"detail": "Quote not found"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def get_quote(
|
||||||
|
access_token: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get a quote by its access token.
|
||||||
|
|
||||||
|
Returns the quote with all its items. This is the public endpoint
|
||||||
|
for viewing a quote.
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```
|
||||||
|
GET /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||||
|
"access_token": "xYz123abc456def789ghi012jkl345mno678pqr901stu",
|
||||||
|
"status": "draft",
|
||||||
|
"company_name": null,
|
||||||
|
"contact_name": null,
|
||||||
|
"contact_email": null,
|
||||||
|
"employee_count": 25,
|
||||||
|
"monthly_total": "450.00",
|
||||||
|
"setup_total": "500.00",
|
||||||
|
"annual_total": "5900.00",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "456e7890-e89b-12d3-a456-426614174001",
|
||||||
|
"service_name": "Managed Endpoint Protection",
|
||||||
|
"category": "security",
|
||||||
|
"unit_price": "15.00",
|
||||||
|
"quantity": 25,
|
||||||
|
"billing_frequency": "monthly",
|
||||||
|
"line_total": "375.00",
|
||||||
|
"monthly_amount": "375.00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": "2024-01-15T10:30:00Z",
|
||||||
|
"updated_at": "2024-01-15T10:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
quote = quote_service.get_quote_by_token(db, access_token)
|
||||||
|
|
||||||
|
# Build response with calculated fields for items
|
||||||
|
items_response = []
|
||||||
|
for item in quote.items:
|
||||||
|
item_dict = QuoteItemResponse(
|
||||||
|
id=item.id,
|
||||||
|
quote_id=item.quote_id,
|
||||||
|
service_name=item.service_name,
|
||||||
|
service_description=item.service_description,
|
||||||
|
category=item.category,
|
||||||
|
billing_frequency=item.billing_frequency,
|
||||||
|
unit_price=item.unit_price,
|
||||||
|
quantity=item.quantity,
|
||||||
|
setup_fee=item.setup_fee,
|
||||||
|
is_required=item.is_required,
|
||||||
|
sort_order=item.sort_order,
|
||||||
|
line_total=item.line_total,
|
||||||
|
monthly_amount=item.monthly_amount,
|
||||||
|
created_at=item.created_at,
|
||||||
|
updated_at=item.updated_at
|
||||||
|
)
|
||||||
|
items_response.append(item_dict)
|
||||||
|
|
||||||
|
return QuoteResponse(
|
||||||
|
id=quote.id,
|
||||||
|
access_token=quote.access_token,
|
||||||
|
status=quote.status,
|
||||||
|
company_name=quote.company_name,
|
||||||
|
contact_name=quote.contact_name,
|
||||||
|
contact_email=quote.contact_email,
|
||||||
|
contact_phone=quote.contact_phone,
|
||||||
|
employee_count=quote.employee_count,
|
||||||
|
notes=quote.notes,
|
||||||
|
monthly_total=quote.monthly_total,
|
||||||
|
setup_total=quote.setup_total,
|
||||||
|
annual_total=quote.annual_total,
|
||||||
|
expires_at=quote.expires_at,
|
||||||
|
submitted_at=quote.submitted_at,
|
||||||
|
created_at=quote.created_at,
|
||||||
|
updated_at=quote.updated_at,
|
||||||
|
items=items_response
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{access_token}",
|
||||||
|
response_model=QuoteResponse,
|
||||||
|
summary="Update quote",
|
||||||
|
description="Update a quote's details and/or items",
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "Quote updated successfully",
|
||||||
|
"model": QuoteResponse,
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
"description": "Quote cannot be modified (not a draft)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {"detail": "Cannot update quote with status 'submitted'. Only drafts can be modified."}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
"description": "Quote not found",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def update_quote(
|
||||||
|
access_token: str,
|
||||||
|
quote_data: QuoteUpdate,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update a quote.
|
||||||
|
|
||||||
|
Updates quote details and/or replaces all items. Only draft quotes
|
||||||
|
can be modified.
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```json
|
||||||
|
PUT /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"employee_count": 30,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"service_name": "Managed Endpoint Protection",
|
||||||
|
"category": "security",
|
||||||
|
"unit_price": "15.00",
|
||||||
|
"quantity": 30,
|
||||||
|
"billing_frequency": "monthly"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service_name": "Cloud Backup",
|
||||||
|
"category": "backup",
|
||||||
|
"unit_price": "5.00",
|
||||||
|
"quantity": 30,
|
||||||
|
"billing_frequency": "monthly",
|
||||||
|
"setup_fee": "250.00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
ip_address = get_client_ip(request)
|
||||||
|
|
||||||
|
quote = quote_service.update_quote(
|
||||||
|
db=db,
|
||||||
|
access_token=access_token,
|
||||||
|
quote_data=quote_data,
|
||||||
|
ip_address=ip_address
|
||||||
|
)
|
||||||
|
|
||||||
|
return get_quote(access_token, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{access_token}/items",
|
||||||
|
response_model=QuoteResponse,
|
||||||
|
summary="Add item to quote",
|
||||||
|
description="Add a single item to the quote",
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
responses={
|
||||||
|
201: {
|
||||||
|
"description": "Item added successfully",
|
||||||
|
"model": QuoteResponse,
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
"description": "Quote cannot be modified",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
"description": "Quote not found",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def add_item(
|
||||||
|
access_token: str,
|
||||||
|
item_data: QuoteItemCreate,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Add a single item to a quote.
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```json
|
||||||
|
POST /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu/items
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"service_name": "24/7 Help Desk Support",
|
||||||
|
"category": "support",
|
||||||
|
"unit_price": "50.00",
|
||||||
|
"quantity": 1,
|
||||||
|
"billing_frequency": "monthly"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
ip_address = get_client_ip(request)
|
||||||
|
|
||||||
|
quote_service.add_item_to_quote(
|
||||||
|
db=db,
|
||||||
|
access_token=access_token,
|
||||||
|
item_data=item_data,
|
||||||
|
ip_address=ip_address
|
||||||
|
)
|
||||||
|
|
||||||
|
return get_quote(access_token, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{access_token}/items/{item_id}",
|
||||||
|
response_model=QuoteResponse,
|
||||||
|
summary="Remove item from quote",
|
||||||
|
description="Remove an item from the quote",
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "Item removed successfully",
|
||||||
|
"model": QuoteResponse,
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
"description": "Quote cannot be modified or item is required",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
"description": "Quote or item not found",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def remove_item(
|
||||||
|
access_token: str,
|
||||||
|
item_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Remove an item from a quote.
|
||||||
|
|
||||||
|
Required items cannot be removed.
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```
|
||||||
|
DELETE /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu/items/456e7890-e89b-12d3-a456-426614174001
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
ip_address = get_client_ip(request)
|
||||||
|
|
||||||
|
quote_service.remove_item_from_quote(
|
||||||
|
db=db,
|
||||||
|
access_token=access_token,
|
||||||
|
item_id=item_id,
|
||||||
|
ip_address=ip_address
|
||||||
|
)
|
||||||
|
|
||||||
|
return get_quote(access_token, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{access_token}/submit",
|
||||||
|
response_model=QuoteResponse,
|
||||||
|
summary="Submit quote",
|
||||||
|
description="Submit the quote with contact information",
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "Quote submitted successfully",
|
||||||
|
"model": QuoteResponse,
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
"description": "Quote cannot be submitted (not a draft or no items)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"examples": {
|
||||||
|
"not_draft": {
|
||||||
|
"value": {"detail": "Cannot submit quote with status 'submitted'. Only drafts can be submitted."}
|
||||||
|
},
|
||||||
|
"no_items": {
|
||||||
|
"value": {"detail": "Cannot submit quote without any items. Please add at least one service."}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
"description": "Quote not found",
|
||||||
|
},
|
||||||
|
422: {
|
||||||
|
"description": "Validation error - missing required fields",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def submit_quote(
|
||||||
|
access_token: str,
|
||||||
|
submit_data: QuoteSubmit,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Submit a quote with contact information.
|
||||||
|
|
||||||
|
This finalizes the quote and sends it for review. Contact information
|
||||||
|
is required at this stage.
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```json
|
||||||
|
POST /api/quotes/xYz123abc456def789ghi012jkl345mno678pqr901stu/submit
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"company_name": "Acme Corporation",
|
||||||
|
"contact_name": "John Doe",
|
||||||
|
"contact_email": "john.doe@acme.com",
|
||||||
|
"contact_phone": "555-123-4567",
|
||||||
|
"notes": "Please contact me to discuss implementation timeline."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||||
|
"access_token": "xYz123abc456def789ghi012jkl345mno678pqr901stu",
|
||||||
|
"status": "submitted",
|
||||||
|
"company_name": "Acme Corporation",
|
||||||
|
"contact_name": "John Doe",
|
||||||
|
"contact_email": "john.doe@acme.com",
|
||||||
|
"submitted_at": "2024-01-15T14:30:00Z",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
ip_address = get_client_ip(request)
|
||||||
|
|
||||||
|
quote_service.submit_quote(
|
||||||
|
db=db,
|
||||||
|
access_token=access_token,
|
||||||
|
submit_data=submit_data,
|
||||||
|
ip_address=ip_address
|
||||||
|
)
|
||||||
|
|
||||||
|
return get_quote(access_token, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{access_token}/pdf",
|
||||||
|
summary="Get quote PDF (placeholder)",
|
||||||
|
description="Generate and return a PDF version of the quote",
|
||||||
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
responses={
|
||||||
|
501: {
|
||||||
|
"description": "PDF generation not yet implemented",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {"detail": "PDF generation is not yet implemented"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def get_quote_pdf(
|
||||||
|
access_token: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate a PDF version of the quote.
|
||||||
|
|
||||||
|
**Note:** This endpoint is a placeholder. PDF generation will be
|
||||||
|
implemented in a future update.
|
||||||
|
"""
|
||||||
|
# Verify quote exists
|
||||||
|
quote_service.get_quote_by_token(db, access_token)
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
detail="PDF generation is not yet implemented"
|
||||||
|
)
|
||||||
@@ -15,6 +15,20 @@ from .m365_tenant import M365TenantBase, M365TenantCreate, M365TenantResponse, M
|
|||||||
from .machine import MachineBase, MachineCreate, MachineResponse, MachineUpdate
|
from .machine import MachineBase, MachineCreate, MachineResponse, MachineUpdate
|
||||||
from .network import NetworkBase, NetworkCreate, NetworkResponse, NetworkUpdate
|
from .network import NetworkBase, NetworkCreate, NetworkResponse, NetworkUpdate
|
||||||
from .project import ProjectBase, ProjectCreate, ProjectResponse, ProjectUpdate
|
from .project import ProjectBase, ProjectCreate, ProjectResponse, ProjectUpdate
|
||||||
|
from .quote import (
|
||||||
|
QuoteCreate,
|
||||||
|
QuoteCreatedResponse,
|
||||||
|
QuoteItemCreate,
|
||||||
|
QuoteItemResponse,
|
||||||
|
QuoteItemUpdate,
|
||||||
|
QuoteListResponse,
|
||||||
|
QuoteResponse,
|
||||||
|
QuoteAdminResponse,
|
||||||
|
QuoteAdminUpdate,
|
||||||
|
QuoteStatsResponse,
|
||||||
|
QuoteSubmit,
|
||||||
|
QuoteUpdate,
|
||||||
|
)
|
||||||
from .security_incident import SecurityIncidentBase, SecurityIncidentCreate, SecurityIncidentResponse, SecurityIncidentUpdate
|
from .security_incident import SecurityIncidentBase, SecurityIncidentCreate, SecurityIncidentResponse, SecurityIncidentUpdate
|
||||||
from .service import ServiceBase, ServiceCreate, ServiceResponse, ServiceUpdate
|
from .service import ServiceBase, ServiceCreate, ServiceResponse, ServiceUpdate
|
||||||
from .session import SessionBase, SessionCreate, SessionResponse, SessionUpdate
|
from .session import SessionBase, SessionCreate, SessionResponse, SessionUpdate
|
||||||
@@ -109,4 +123,17 @@ __all__ = [
|
|||||||
"SecurityIncidentCreate",
|
"SecurityIncidentCreate",
|
||||||
"SecurityIncidentUpdate",
|
"SecurityIncidentUpdate",
|
||||||
"SecurityIncidentResponse",
|
"SecurityIncidentResponse",
|
||||||
|
# Quote schemas
|
||||||
|
"QuoteCreate",
|
||||||
|
"QuoteCreatedResponse",
|
||||||
|
"QuoteItemCreate",
|
||||||
|
"QuoteItemResponse",
|
||||||
|
"QuoteItemUpdate",
|
||||||
|
"QuoteListResponse",
|
||||||
|
"QuoteResponse",
|
||||||
|
"QuoteAdminResponse",
|
||||||
|
"QuoteAdminUpdate",
|
||||||
|
"QuoteStatsResponse",
|
||||||
|
"QuoteSubmit",
|
||||||
|
"QuoteUpdate",
|
||||||
]
|
]
|
||||||
|
|||||||
303
api/schemas/quote.py
Normal file
303
api/schemas/quote.py
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schemas for Quote models.
|
||||||
|
|
||||||
|
Request and response schemas for the MSP Quote Wizard including
|
||||||
|
public and admin-facing operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, EmailStr, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteStatus(str, Enum):
|
||||||
|
"""Status options for quotes."""
|
||||||
|
DRAFT = "draft"
|
||||||
|
SUBMITTED = "submitted"
|
||||||
|
REVIEWING = "reviewing"
|
||||||
|
APPROVED = "approved"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceCategory(str, Enum):
|
||||||
|
"""Service category options for quote items."""
|
||||||
|
MANAGED_SERVICES = "managed_services"
|
||||||
|
SECURITY = "security"
|
||||||
|
BACKUP = "backup"
|
||||||
|
CLOUD = "cloud"
|
||||||
|
HARDWARE = "hardware"
|
||||||
|
SOFTWARE = "software"
|
||||||
|
CONSULTING = "consulting"
|
||||||
|
SUPPORT = "support"
|
||||||
|
|
||||||
|
|
||||||
|
class BillingFrequency(str, Enum):
|
||||||
|
"""Billing frequency options for quote items."""
|
||||||
|
MONTHLY = "monthly"
|
||||||
|
QUARTERLY = "quarterly"
|
||||||
|
ANNUAL = "annual"
|
||||||
|
ONE_TIME = "one_time"
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationType(str, Enum):
|
||||||
|
"""Notification types for quote events."""
|
||||||
|
EMAIL_SENT = "email_sent"
|
||||||
|
SMS_SENT = "sms_sent"
|
||||||
|
ADMIN_ALERT = "admin_alert"
|
||||||
|
REMINDER_SENT = "reminder_sent"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Quote Item Schemas
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class QuoteItemBase(BaseModel):
|
||||||
|
"""Base schema with shared QuoteItem fields."""
|
||||||
|
|
||||||
|
service_name: str = Field(..., description="Name of the service", min_length=1, max_length=255)
|
||||||
|
service_description: Optional[str] = Field(None, description="Detailed description of the service")
|
||||||
|
category: ServiceCategory = Field(
|
||||||
|
ServiceCategory.MANAGED_SERVICES,
|
||||||
|
description="Service category"
|
||||||
|
)
|
||||||
|
billing_frequency: BillingFrequency = Field(
|
||||||
|
BillingFrequency.MONTHLY,
|
||||||
|
description="Billing frequency"
|
||||||
|
)
|
||||||
|
unit_price: Decimal = Field(..., description="Price per unit", ge=0)
|
||||||
|
quantity: int = Field(1, description="Number of units", ge=1)
|
||||||
|
setup_fee: Decimal = Field(Decimal("0.00"), description="One-time setup fee", ge=0)
|
||||||
|
is_required: bool = Field(False, description="Whether this item is required")
|
||||||
|
sort_order: int = Field(0, description="Display order within the quote")
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteItemCreate(QuoteItemBase):
|
||||||
|
"""Schema for creating a new QuoteItem."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteItemUpdate(BaseModel):
|
||||||
|
"""Schema for updating an existing QuoteItem. All fields optional."""
|
||||||
|
|
||||||
|
service_name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
service_description: Optional[str] = None
|
||||||
|
category: Optional[ServiceCategory] = None
|
||||||
|
billing_frequency: Optional[BillingFrequency] = None
|
||||||
|
unit_price: Optional[Decimal] = Field(None, ge=0)
|
||||||
|
quantity: Optional[int] = Field(None, ge=1)
|
||||||
|
setup_fee: Optional[Decimal] = Field(None, ge=0)
|
||||||
|
is_required: Optional[bool] = None
|
||||||
|
sort_order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteItemResponse(QuoteItemBase):
|
||||||
|
"""Schema for QuoteItem responses with ID and computed fields."""
|
||||||
|
|
||||||
|
id: UUID = Field(..., description="Unique identifier for the quote item")
|
||||||
|
quote_id: UUID = Field(..., description="Reference to the parent quote")
|
||||||
|
line_total: Decimal = Field(..., description="Calculated line total (unit_price * quantity)")
|
||||||
|
monthly_amount: Decimal = Field(..., description="Calculated monthly amount")
|
||||||
|
created_at: datetime = Field(..., description="Timestamp when item was created")
|
||||||
|
updated_at: datetime = Field(..., description="Timestamp when item was last updated")
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Quote Schemas
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class QuoteBase(BaseModel):
|
||||||
|
"""Base schema with shared Quote fields."""
|
||||||
|
|
||||||
|
company_name: Optional[str] = Field(None, description="Prospect company name", max_length=255)
|
||||||
|
contact_name: Optional[str] = Field(None, description="Primary contact name", max_length=255)
|
||||||
|
contact_email: Optional[EmailStr] = Field(None, description="Contact email address")
|
||||||
|
contact_phone: Optional[str] = Field(None, description="Contact phone number", max_length=50)
|
||||||
|
employee_count: Optional[int] = Field(None, description="Number of employees/users", ge=1)
|
||||||
|
notes: Optional[str] = Field(None, description="Customer notes or special requirements")
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteCreate(BaseModel):
|
||||||
|
"""Schema for creating a new Quote draft."""
|
||||||
|
|
||||||
|
employee_count: Optional[int] = Field(None, description="Number of employees/users", ge=1)
|
||||||
|
notes: Optional[str] = Field(None, description="Initial notes")
|
||||||
|
# Items can optionally be provided at creation
|
||||||
|
items: Optional[list[QuoteItemCreate]] = Field(None, description="Initial quote items")
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteUpdate(BaseModel):
|
||||||
|
"""Schema for updating a Quote during wizard flow."""
|
||||||
|
|
||||||
|
company_name: Optional[str] = Field(None, max_length=255)
|
||||||
|
contact_name: Optional[str] = Field(None, max_length=255)
|
||||||
|
contact_email: Optional[EmailStr] = None
|
||||||
|
contact_phone: Optional[str] = Field(None, max_length=50)
|
||||||
|
employee_count: Optional[int] = Field(None, ge=1)
|
||||||
|
notes: Optional[str] = None
|
||||||
|
# Items to add/update
|
||||||
|
items: Optional[list[QuoteItemCreate]] = Field(None, description="Items to set (replaces existing)")
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteSubmit(BaseModel):
|
||||||
|
"""Schema for final quote submission with required contact info."""
|
||||||
|
|
||||||
|
company_name: str = Field(..., description="Company name (required for submission)", min_length=1, max_length=255)
|
||||||
|
contact_name: str = Field(..., description="Contact name (required for submission)", min_length=1, max_length=255)
|
||||||
|
contact_email: EmailStr = Field(..., description="Email address (required for submission)")
|
||||||
|
contact_phone: Optional[str] = Field(None, description="Phone number", max_length=50)
|
||||||
|
notes: Optional[str] = Field(None, description="Additional notes")
|
||||||
|
|
||||||
|
@field_validator("company_name", "contact_name")
|
||||||
|
@classmethod
|
||||||
|
def strip_whitespace(cls, v: str) -> str:
|
||||||
|
"""Strip whitespace from string fields."""
|
||||||
|
return v.strip() if v else v
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteResponse(QuoteBase):
|
||||||
|
"""Schema for public Quote responses with items."""
|
||||||
|
|
||||||
|
id: UUID = Field(..., description="Unique identifier for the quote")
|
||||||
|
access_token: str = Field(..., description="Access token for public URL")
|
||||||
|
status: QuoteStatus = Field(..., description="Current quote status")
|
||||||
|
monthly_total: Decimal = Field(..., description="Calculated monthly recurring total")
|
||||||
|
setup_total: Decimal = Field(..., description="Calculated one-time setup total")
|
||||||
|
annual_total: Decimal = Field(..., description="Calculated annual total")
|
||||||
|
expires_at: Optional[datetime] = Field(None, description="Quote expiration date")
|
||||||
|
submitted_at: Optional[datetime] = Field(None, description="When quote was submitted")
|
||||||
|
created_at: datetime = Field(..., description="Timestamp when quote was created")
|
||||||
|
updated_at: datetime = Field(..., description="Timestamp when quote was last updated")
|
||||||
|
items: list[QuoteItemResponse] = Field(default_factory=list, description="Quote line items")
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteCreatedResponse(BaseModel):
|
||||||
|
"""Schema for quote creation response with access URL info."""
|
||||||
|
|
||||||
|
id: UUID = Field(..., description="Unique identifier for the quote")
|
||||||
|
access_token: str = Field(..., description="Access token for public URL")
|
||||||
|
status: QuoteStatus = Field(..., description="Current quote status")
|
||||||
|
message: str = Field(..., description="Success message")
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Quote Activity Schemas
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class QuoteActivityResponse(BaseModel):
|
||||||
|
"""Schema for QuoteActivity responses."""
|
||||||
|
|
||||||
|
id: UUID = Field(..., description="Unique identifier for the activity")
|
||||||
|
quote_id: UUID = Field(..., description="Reference to the parent quote")
|
||||||
|
action: str = Field(..., description="Action performed")
|
||||||
|
description: Optional[str] = Field(None, description="Detailed description")
|
||||||
|
actor: Optional[str] = Field(None, description="Who performed the action")
|
||||||
|
ip_address: Optional[str] = Field(None, description="IP address of the actor")
|
||||||
|
created_at: datetime = Field(..., description="Timestamp of the action")
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Quote Notification Schemas
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class QuoteNotificationResponse(BaseModel):
|
||||||
|
"""Schema for QuoteNotification responses."""
|
||||||
|
|
||||||
|
id: UUID = Field(..., description="Unique identifier for the notification")
|
||||||
|
quote_id: UUID = Field(..., description="Reference to the parent quote")
|
||||||
|
notification_type: NotificationType = Field(..., description="Type of notification")
|
||||||
|
recipient: str = Field(..., description="Notification recipient")
|
||||||
|
subject: Optional[str] = Field(None, description="Notification subject")
|
||||||
|
status: str = Field(..., description="Delivery status")
|
||||||
|
sent_at: Optional[datetime] = Field(None, description="When notification was sent")
|
||||||
|
error_message: Optional[str] = Field(None, description="Error message if failed")
|
||||||
|
created_at: datetime = Field(..., description="Timestamp when created")
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Admin Schemas
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class QuoteAdminUpdate(BaseModel):
|
||||||
|
"""Schema for admin updates to a quote."""
|
||||||
|
|
||||||
|
status: Optional[QuoteStatus] = Field(None, description="New status")
|
||||||
|
admin_notes: Optional[str] = Field(None, description="Internal admin notes")
|
||||||
|
expires_at: Optional[datetime] = Field(None, description="Quote expiration date")
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteAdminResponse(QuoteResponse):
|
||||||
|
"""Schema for admin Quote responses with additional fields."""
|
||||||
|
|
||||||
|
admin_notes: Optional[str] = Field(None, description="Internal admin notes")
|
||||||
|
ip_address: Optional[str] = Field(None, description="IP address of the requester")
|
||||||
|
user_agent: Optional[str] = Field(None, description="Browser user agent")
|
||||||
|
activities: list[QuoteActivityResponse] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Activity log for this quote"
|
||||||
|
)
|
||||||
|
notifications: list[QuoteNotificationResponse] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Notifications sent for this quote"
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# List and Stats Schemas
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class QuoteListItem(BaseModel):
|
||||||
|
"""Schema for quote list items (summary view)."""
|
||||||
|
|
||||||
|
id: UUID = Field(..., description="Unique identifier")
|
||||||
|
access_token: str = Field(..., description="Access token")
|
||||||
|
status: QuoteStatus = Field(..., description="Current status")
|
||||||
|
company_name: Optional[str] = Field(None, description="Company name")
|
||||||
|
contact_name: Optional[str] = Field(None, description="Contact name")
|
||||||
|
contact_email: Optional[str] = Field(None, description="Contact email")
|
||||||
|
employee_count: Optional[int] = Field(None, description="Employee count")
|
||||||
|
monthly_total: Decimal = Field(..., description="Monthly total")
|
||||||
|
setup_total: Decimal = Field(..., description="Setup total")
|
||||||
|
item_count: int = Field(..., description="Number of line items")
|
||||||
|
submitted_at: Optional[datetime] = Field(None, description="Submission timestamp")
|
||||||
|
created_at: datetime = Field(..., description="Creation timestamp")
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteListResponse(BaseModel):
|
||||||
|
"""Schema for paginated quote list responses."""
|
||||||
|
|
||||||
|
total: int = Field(..., description="Total number of quotes matching filters")
|
||||||
|
skip: int = Field(..., description="Number of records skipped")
|
||||||
|
limit: int = Field(..., description="Maximum number of records returned")
|
||||||
|
quotes: list[QuoteListItem] = Field(..., description="List of quotes")
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteStatsResponse(BaseModel):
|
||||||
|
"""Schema for admin dashboard statistics."""
|
||||||
|
|
||||||
|
total_quotes: int = Field(..., description="Total number of quotes")
|
||||||
|
quotes_by_status: dict[str, int] = Field(..., description="Quote count by status")
|
||||||
|
total_monthly_value: Decimal = Field(..., description="Total monthly value of all submitted quotes")
|
||||||
|
total_setup_value: Decimal = Field(..., description="Total setup value of all submitted quotes")
|
||||||
|
quotes_this_month: int = Field(..., description="Quotes created this month")
|
||||||
|
quotes_submitted_this_month: int = Field(..., description="Quotes submitted this month")
|
||||||
|
average_monthly_value: Decimal = Field(..., description="Average monthly value per submitted quote")
|
||||||
|
conversion_rate: Decimal = Field(..., description="Percentage of drafts that get submitted")
|
||||||
@@ -11,6 +11,8 @@ from . import (
|
|||||||
credential_service,
|
credential_service,
|
||||||
credential_audit_log_service,
|
credential_audit_log_service,
|
||||||
security_incident_service,
|
security_incident_service,
|
||||||
|
quote_service,
|
||||||
|
syncro_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -24,4 +26,6 @@ __all__ = [
|
|||||||
"credential_service",
|
"credential_service",
|
||||||
"credential_audit_log_service",
|
"credential_audit_log_service",
|
||||||
"security_incident_service",
|
"security_incident_service",
|
||||||
|
"quote_service",
|
||||||
|
"syncro_service",
|
||||||
]
|
]
|
||||||
|
|||||||
985
api/services/quote_service.py
Normal file
985
api/services/quote_service.py
Normal file
@@ -0,0 +1,985 @@
|
|||||||
|
"""
|
||||||
|
Quote service layer for business logic and database operations.
|
||||||
|
|
||||||
|
This module handles all database operations for quotes, providing a clean
|
||||||
|
separation between the API routes and data access layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
|
from api.models.quote import (
|
||||||
|
Quote,
|
||||||
|
QuoteActivity,
|
||||||
|
QuoteItem,
|
||||||
|
QuoteNotification,
|
||||||
|
QuoteStatus,
|
||||||
|
BillingFrequency,
|
||||||
|
)
|
||||||
|
from api.schemas.quote import (
|
||||||
|
QuoteCreate,
|
||||||
|
QuoteUpdate,
|
||||||
|
QuoteSubmit,
|
||||||
|
QuoteItemCreate,
|
||||||
|
QuoteAdminUpdate,
|
||||||
|
QuoteListItem,
|
||||||
|
QuoteStatsResponse,
|
||||||
|
)
|
||||||
|
from api.services.syncro_service import get_syncro_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_access_token() -> str:
|
||||||
|
"""
|
||||||
|
Generate a secure, URL-safe access token for quote access.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: A 43-character URL-safe token
|
||||||
|
"""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_totals(items: list[QuoteItem]) -> tuple[Decimal, Decimal, Decimal]:
|
||||||
|
"""
|
||||||
|
Calculate monthly, setup, and annual totals from quote items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: List of QuoteItem objects
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (monthly_total, setup_total, annual_total)
|
||||||
|
"""
|
||||||
|
monthly_total = Decimal("0.00")
|
||||||
|
setup_total = Decimal("0.00")
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
# Calculate line total
|
||||||
|
line_total = item.unit_price * item.quantity
|
||||||
|
|
||||||
|
# Add to appropriate total based on billing frequency
|
||||||
|
if item.billing_frequency == BillingFrequency.MONTHLY.value:
|
||||||
|
monthly_total += line_total
|
||||||
|
elif item.billing_frequency == BillingFrequency.QUARTERLY.value:
|
||||||
|
monthly_total += line_total / Decimal("3")
|
||||||
|
elif item.billing_frequency == BillingFrequency.ANNUAL.value:
|
||||||
|
monthly_total += line_total / Decimal("12")
|
||||||
|
# one_time items don't add to monthly
|
||||||
|
|
||||||
|
# Setup fees are always one-time
|
||||||
|
setup_total += item.setup_fee
|
||||||
|
|
||||||
|
# Annual total is monthly * 12 + setup
|
||||||
|
annual_total = (monthly_total * Decimal("12")) + setup_total
|
||||||
|
|
||||||
|
return monthly_total, setup_total, annual_total
|
||||||
|
|
||||||
|
|
||||||
|
def log_activity(
|
||||||
|
db: Session,
|
||||||
|
quote_id: str,
|
||||||
|
action: str,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
actor: Optional[str] = None,
|
||||||
|
ip_address: Optional[str] = None,
|
||||||
|
metadata: Optional[dict] = None
|
||||||
|
) -> QuoteActivity:
|
||||||
|
"""
|
||||||
|
Log an activity for a quote.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
quote_id: UUID of the quote
|
||||||
|
action: Action being performed
|
||||||
|
description: Detailed description
|
||||||
|
actor: Who performed the action
|
||||||
|
ip_address: IP address of the actor
|
||||||
|
metadata: Additional metadata as dict
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuoteActivity: The created activity record
|
||||||
|
"""
|
||||||
|
activity = QuoteActivity(
|
||||||
|
quote_id=quote_id,
|
||||||
|
action=action,
|
||||||
|
description=description,
|
||||||
|
actor=actor,
|
||||||
|
ip_address=ip_address,
|
||||||
|
metadata=json.dumps(metadata) if metadata else None
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(activity)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
return activity
|
||||||
|
|
||||||
|
|
||||||
|
def create_quote(
|
||||||
|
db: Session,
|
||||||
|
quote_data: QuoteCreate,
|
||||||
|
ip_address: Optional[str] = None,
|
||||||
|
user_agent: Optional[str] = None
|
||||||
|
) -> Quote:
|
||||||
|
"""
|
||||||
|
Create a new quote draft with access token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
quote_data: Quote creation data
|
||||||
|
ip_address: IP address of the requester
|
||||||
|
user_agent: Browser user agent
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Quote: The created quote object
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
quote_data = QuoteCreate(employee_count=25)
|
||||||
|
quote = create_quote(db, quote_data, ip_address="192.168.1.1")
|
||||||
|
print(f"Quote created: {quote.access_token}")
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Create quote with unique access token
|
||||||
|
quote = Quote(
|
||||||
|
access_token=generate_access_token(),
|
||||||
|
status=QuoteStatus.DRAFT.value,
|
||||||
|
employee_count=quote_data.employee_count,
|
||||||
|
notes=quote_data.notes,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent,
|
||||||
|
# Set expiration to 30 days from now
|
||||||
|
expires_at=datetime.utcnow() + timedelta(days=30)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(quote)
|
||||||
|
db.flush() # Get the quote ID
|
||||||
|
|
||||||
|
# Add initial items if provided
|
||||||
|
if quote_data.items:
|
||||||
|
for idx, item_data in enumerate(quote_data.items):
|
||||||
|
item = QuoteItem(
|
||||||
|
quote_id=quote.id,
|
||||||
|
service_name=item_data.service_name,
|
||||||
|
service_description=item_data.service_description,
|
||||||
|
category=item_data.category.value,
|
||||||
|
billing_frequency=item_data.billing_frequency.value,
|
||||||
|
unit_price=item_data.unit_price,
|
||||||
|
quantity=item_data.quantity,
|
||||||
|
setup_fee=item_data.setup_fee,
|
||||||
|
is_required=item_data.is_required,
|
||||||
|
sort_order=item_data.sort_order if item_data.sort_order else idx
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Calculate and update totals
|
||||||
|
monthly, setup, annual = calculate_totals(quote.items)
|
||||||
|
quote.monthly_total = monthly
|
||||||
|
quote.setup_total = setup
|
||||||
|
quote.annual_total = annual
|
||||||
|
|
||||||
|
# Log activity
|
||||||
|
log_activity(
|
||||||
|
db=db,
|
||||||
|
quote_id=quote.id,
|
||||||
|
action="created",
|
||||||
|
description="Quote draft created",
|
||||||
|
ip_address=ip_address,
|
||||||
|
metadata={"employee_count": quote_data.employee_count}
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(quote)
|
||||||
|
|
||||||
|
return quote
|
||||||
|
|
||||||
|
except IntegrityError as e:
|
||||||
|
db.rollback()
|
||||||
|
# If token collision (extremely rare), retry once
|
||||||
|
if "access_token" in str(e.orig):
|
||||||
|
return create_quote(db, quote_data, ip_address, user_agent)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Database error: {str(e)}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to create quote: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_quote_by_token(db: Session, access_token: str) -> Quote:
|
||||||
|
"""
|
||||||
|
Retrieve a quote by its access token (public access).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
access_token: The quote's access token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Quote: The quote object with items loaded
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if quote not found
|
||||||
|
"""
|
||||||
|
quote = (
|
||||||
|
db.query(Quote)
|
||||||
|
.options(joinedload(Quote.items))
|
||||||
|
.filter(Quote.access_token == access_token)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not quote:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Quote not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if expired
|
||||||
|
if quote.expires_at and quote.expires_at < datetime.utcnow():
|
||||||
|
if quote.status == QuoteStatus.DRAFT.value:
|
||||||
|
quote.status = QuoteStatus.EXPIRED.value
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return quote
|
||||||
|
|
||||||
|
|
||||||
|
def get_quote_by_id(db: Session, quote_id: UUID) -> Quote:
|
||||||
|
"""
|
||||||
|
Retrieve a quote by its ID (admin access).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
quote_id: UUID of the quote
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Quote: The quote object with all related data loaded
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if quote not found
|
||||||
|
"""
|
||||||
|
quote = (
|
||||||
|
db.query(Quote)
|
||||||
|
.options(
|
||||||
|
joinedload(Quote.items),
|
||||||
|
joinedload(Quote.activities),
|
||||||
|
joinedload(Quote.notifications)
|
||||||
|
)
|
||||||
|
.filter(Quote.id == str(quote_id))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not quote:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Quote with ID {quote_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return quote
|
||||||
|
|
||||||
|
|
||||||
|
def update_quote(
|
||||||
|
db: Session,
|
||||||
|
access_token: str,
|
||||||
|
quote_data: QuoteUpdate,
|
||||||
|
ip_address: Optional[str] = None
|
||||||
|
) -> Quote:
|
||||||
|
"""
|
||||||
|
Update a quote (add/remove items, update details).
|
||||||
|
|
||||||
|
Only drafts can be updated. Replaces all items if items are provided.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
access_token: The quote's access token
|
||||||
|
quote_data: Update data
|
||||||
|
ip_address: IP address of the requester
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Quote: The updated quote object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if not found, 400 if not a draft
|
||||||
|
"""
|
||||||
|
quote = get_quote_by_token(db, access_token)
|
||||||
|
|
||||||
|
# Only drafts can be updated
|
||||||
|
if quote.status != QuoteStatus.DRAFT.value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Cannot update quote with status '{quote.status}'. Only drafts can be modified."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update basic fields if provided
|
||||||
|
update_data = quote_data.model_dump(exclude_unset=True, exclude={"items"})
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
old_value = getattr(quote, field)
|
||||||
|
if old_value != value:
|
||||||
|
setattr(quote, field, value)
|
||||||
|
changes.append(f"{field}: {old_value} -> {value}")
|
||||||
|
|
||||||
|
# Replace items if provided
|
||||||
|
if quote_data.items is not None:
|
||||||
|
# Remove existing items
|
||||||
|
db.query(QuoteItem).filter(QuoteItem.quote_id == quote.id).delete()
|
||||||
|
|
||||||
|
# Add new items
|
||||||
|
for idx, item_data in enumerate(quote_data.items):
|
||||||
|
item = QuoteItem(
|
||||||
|
quote_id=quote.id,
|
||||||
|
service_name=item_data.service_name,
|
||||||
|
service_description=item_data.service_description,
|
||||||
|
category=item_data.category.value,
|
||||||
|
billing_frequency=item_data.billing_frequency.value,
|
||||||
|
unit_price=item_data.unit_price,
|
||||||
|
quantity=item_data.quantity,
|
||||||
|
setup_fee=item_data.setup_fee,
|
||||||
|
is_required=item_data.is_required,
|
||||||
|
sort_order=item_data.sort_order if item_data.sort_order else idx
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
|
||||||
|
changes.append(f"items: replaced with {len(quote_data.items)} items")
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Recalculate totals
|
||||||
|
db.refresh(quote)
|
||||||
|
monthly, setup, annual = calculate_totals(quote.items)
|
||||||
|
quote.monthly_total = monthly
|
||||||
|
quote.setup_total = setup
|
||||||
|
quote.annual_total = annual
|
||||||
|
|
||||||
|
# Log activity
|
||||||
|
if changes:
|
||||||
|
log_activity(
|
||||||
|
db=db,
|
||||||
|
quote_id=quote.id,
|
||||||
|
action="updated",
|
||||||
|
description=f"Quote updated: {', '.join(changes)}",
|
||||||
|
ip_address=ip_address
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(quote)
|
||||||
|
|
||||||
|
return quote
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to update quote: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def submit_quote(
|
||||||
|
db: Session,
|
||||||
|
access_token: str,
|
||||||
|
submit_data: QuoteSubmit,
|
||||||
|
ip_address: Optional[str] = None
|
||||||
|
) -> Quote:
|
||||||
|
"""
|
||||||
|
Submit a quote with contact information.
|
||||||
|
|
||||||
|
Transitions quote from draft to submitted status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
access_token: The quote's access token
|
||||||
|
submit_data: Submission data with required contact info
|
||||||
|
ip_address: IP address of the requester
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Quote: The submitted quote object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if not found, 400 if not a draft or no items
|
||||||
|
"""
|
||||||
|
quote = get_quote_by_token(db, access_token)
|
||||||
|
|
||||||
|
# Only drafts can be submitted
|
||||||
|
if quote.status != QuoteStatus.DRAFT.value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Cannot submit quote with status '{quote.status}'. Only drafts can be submitted."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Must have at least one item
|
||||||
|
if not quote.items:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot submit quote without any items. Please add at least one service."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update contact information
|
||||||
|
quote.company_name = submit_data.company_name
|
||||||
|
quote.contact_name = submit_data.contact_name
|
||||||
|
quote.contact_email = submit_data.contact_email
|
||||||
|
quote.contact_phone = submit_data.contact_phone
|
||||||
|
if submit_data.notes:
|
||||||
|
quote.notes = submit_data.notes
|
||||||
|
|
||||||
|
# Update status and timestamp
|
||||||
|
quote.status = QuoteStatus.SUBMITTED.value
|
||||||
|
quote.submitted_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# Extend expiration to 90 days from submission
|
||||||
|
quote.expires_at = datetime.utcnow() + timedelta(days=90)
|
||||||
|
|
||||||
|
# Log activity
|
||||||
|
log_activity(
|
||||||
|
db=db,
|
||||||
|
quote_id=quote.id,
|
||||||
|
action="submitted",
|
||||||
|
description=f"Quote submitted by {submit_data.contact_name} ({submit_data.contact_email})",
|
||||||
|
actor=submit_data.contact_email,
|
||||||
|
ip_address=ip_address,
|
||||||
|
metadata={
|
||||||
|
"company_name": submit_data.company_name,
|
||||||
|
"contact_email": submit_data.contact_email,
|
||||||
|
"monthly_total": str(quote.monthly_total),
|
||||||
|
"setup_total": str(quote.setup_total)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create admin notification record (actual sending would be handled elsewhere)
|
||||||
|
notification = QuoteNotification(
|
||||||
|
quote_id=quote.id,
|
||||||
|
notification_type="admin_alert",
|
||||||
|
recipient="admin@example.com", # Would come from config in production
|
||||||
|
subject=f"New Quote Submission: {submit_data.company_name}",
|
||||||
|
content=f"Quote submitted by {submit_data.contact_name}. Monthly: ${quote.monthly_total}",
|
||||||
|
status="pending"
|
||||||
|
)
|
||||||
|
db.add(notification)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(quote)
|
||||||
|
|
||||||
|
return quote
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to submit quote: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_quote_to_syncro(db: Session, quote: Quote) -> dict:
|
||||||
|
"""
|
||||||
|
Sync a submitted quote to SyncroRMM.
|
||||||
|
|
||||||
|
Checks for existing customer and creates a lead in Syncro. Updates the
|
||||||
|
quote with sync status and existing customer flag.
|
||||||
|
|
||||||
|
This function is designed to be called after submit_quote() completes,
|
||||||
|
typically as a background task or in the API endpoint. It handles all
|
||||||
|
Syncro API errors gracefully to avoid blocking the quote submission.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
quote: The submitted quote object (must have contact_email)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Sync result with keys:
|
||||||
|
- synced: bool - Whether lead was created successfully
|
||||||
|
- is_existing_customer: bool - Whether customer already exists
|
||||||
|
- syncro_lead_id: str|None - Lead ID if created
|
||||||
|
- error: str|None - Error message if sync failed
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
quote = submit_quote(db, access_token, submit_data, ip_address)
|
||||||
|
sync_result = await sync_quote_to_syncro(db, quote)
|
||||||
|
if sync_result["synced"]:
|
||||||
|
print(f"Lead created: {sync_result['syncro_lead_id']}")
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
"synced": False,
|
||||||
|
"is_existing_customer": False,
|
||||||
|
"syncro_lead_id": None,
|
||||||
|
"error": None
|
||||||
|
}
|
||||||
|
|
||||||
|
if not quote.contact_email:
|
||||||
|
result["error"] = "Quote has no contact email"
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
syncro = get_syncro_service()
|
||||||
|
|
||||||
|
# Check for existing customer
|
||||||
|
customer_check = await syncro.check_existing_customer(
|
||||||
|
email=quote.contact_email,
|
||||||
|
business_name=quote.company_name
|
||||||
|
)
|
||||||
|
|
||||||
|
if customer_check.exists:
|
||||||
|
quote.is_existing_customer = True
|
||||||
|
result["is_existing_customer"] = True
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Quote {quote.id} is from existing customer: "
|
||||||
|
f"{customer_check.customer_name} (ID: {customer_check.customer_id}, "
|
||||||
|
f"match: {customer_check.match_type})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log activity for existing customer
|
||||||
|
log_activity(
|
||||||
|
db=db,
|
||||||
|
quote_id=quote.id,
|
||||||
|
action="syncro_customer_found",
|
||||||
|
description=f"Existing Syncro customer found: {customer_check.customer_name}",
|
||||||
|
metadata={
|
||||||
|
"syncro_customer_id": customer_check.customer_id,
|
||||||
|
"match_type": customer_check.match_type
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create lead in Syncro
|
||||||
|
lead_result = await syncro.create_lead(quote)
|
||||||
|
|
||||||
|
if lead_result.success:
|
||||||
|
quote.syncro_lead_id = lead_result.lead_id
|
||||||
|
quote.syncro_synced_at = datetime.utcnow()
|
||||||
|
result["synced"] = True
|
||||||
|
result["syncro_lead_id"] = lead_result.lead_id
|
||||||
|
|
||||||
|
# Log activity for successful sync
|
||||||
|
log_activity(
|
||||||
|
db=db,
|
||||||
|
quote_id=quote.id,
|
||||||
|
action="syncro_lead_created",
|
||||||
|
description=f"Lead created in Syncro: {lead_result.lead_id}",
|
||||||
|
metadata={
|
||||||
|
"syncro_lead_id": lead_result.lead_id,
|
||||||
|
"is_existing_customer": customer_check.exists
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result["error"] = lead_result.error
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to create Syncro lead for quote {quote.id}: {lead_result.error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log activity for failed sync
|
||||||
|
log_activity(
|
||||||
|
db=db,
|
||||||
|
quote_id=quote.id,
|
||||||
|
action="syncro_sync_failed",
|
||||||
|
description=f"Failed to sync to Syncro: {lead_result.error}",
|
||||||
|
metadata={"error": lead_result.error}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Commit the updates to quote
|
||||||
|
db.commit()
|
||||||
|
db.refresh(quote)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but don't fail the overall operation
|
||||||
|
error_msg = str(e)
|
||||||
|
result["error"] = error_msg
|
||||||
|
logger.error(
|
||||||
|
f"Unexpected error syncing quote {quote.id} to Syncro: {error_msg}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
log_activity(
|
||||||
|
db=db,
|
||||||
|
quote_id=quote.id,
|
||||||
|
action="syncro_sync_error",
|
||||||
|
description=f"Syncro sync error: {error_msg}",
|
||||||
|
metadata={"error": error_msg}
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def list_quotes(
|
||||||
|
db: Session,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
status_filter: Optional[str] = None,
|
||||||
|
search: Optional[str] = None
|
||||||
|
) -> tuple[list[Quote], int]:
|
||||||
|
"""
|
||||||
|
List quotes with pagination and optional filters (admin).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
skip: Number of records to skip
|
||||||
|
limit: Maximum number of records to return
|
||||||
|
status_filter: Filter by status
|
||||||
|
search: Search in company_name, contact_name, contact_email
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (list of quotes, total count)
|
||||||
|
"""
|
||||||
|
query = db.query(Quote).options(joinedload(Quote.items))
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if status_filter:
|
||||||
|
query = query.filter(Quote.status == status_filter)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
search_term = f"%{search}%"
|
||||||
|
query = query.filter(
|
||||||
|
(Quote.company_name.ilike(search_term)) |
|
||||||
|
(Quote.contact_name.ilike(search_term)) |
|
||||||
|
(Quote.contact_email.ilike(search_term))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get total count before pagination
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
# Apply pagination and ordering
|
||||||
|
quotes = (
|
||||||
|
query
|
||||||
|
.order_by(Quote.created_at.desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return quotes, total
|
||||||
|
|
||||||
|
|
||||||
|
def update_quote_status(
|
||||||
|
db: Session,
|
||||||
|
quote_id: UUID,
|
||||||
|
update_data: QuoteAdminUpdate,
|
||||||
|
admin_user: str
|
||||||
|
) -> Quote:
|
||||||
|
"""
|
||||||
|
Update quote status and admin notes (admin).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
quote_id: UUID of the quote
|
||||||
|
update_data: Admin update data
|
||||||
|
admin_user: Username of the admin making the change
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Quote: The updated quote object
|
||||||
|
"""
|
||||||
|
quote = get_quote_by_id(db, quote_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
if update_data.status is not None and update_data.status.value != quote.status:
|
||||||
|
old_status = quote.status
|
||||||
|
quote.status = update_data.status.value
|
||||||
|
changes.append(f"status: {old_status} -> {update_data.status.value}")
|
||||||
|
|
||||||
|
if update_data.admin_notes is not None:
|
||||||
|
quote.admin_notes = update_data.admin_notes
|
||||||
|
changes.append("admin_notes updated")
|
||||||
|
|
||||||
|
if update_data.expires_at is not None:
|
||||||
|
quote.expires_at = update_data.expires_at
|
||||||
|
changes.append(f"expires_at: {update_data.expires_at}")
|
||||||
|
|
||||||
|
# Log activity
|
||||||
|
if changes:
|
||||||
|
log_activity(
|
||||||
|
db=db,
|
||||||
|
quote_id=quote.id,
|
||||||
|
action="admin_update",
|
||||||
|
description=f"Admin update: {', '.join(changes)}",
|
||||||
|
actor=admin_user
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(quote)
|
||||||
|
|
||||||
|
return quote
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to update quote status: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_quote_stats(db: Session) -> QuoteStatsResponse:
|
||||||
|
"""
|
||||||
|
Get dashboard statistics for quotes (admin).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuoteStatsResponse: Statistics about quotes
|
||||||
|
"""
|
||||||
|
# Total quotes
|
||||||
|
total_quotes = db.query(Quote).count()
|
||||||
|
|
||||||
|
# Quotes by status
|
||||||
|
status_counts = (
|
||||||
|
db.query(Quote.status, func.count(Quote.id))
|
||||||
|
.group_by(Quote.status)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
quotes_by_status = {status: count for status, count in status_counts}
|
||||||
|
|
||||||
|
# Total values for submitted quotes
|
||||||
|
submitted_statuses = [
|
||||||
|
QuoteStatus.SUBMITTED.value,
|
||||||
|
QuoteStatus.REVIEWING.value,
|
||||||
|
QuoteStatus.APPROVED.value
|
||||||
|
]
|
||||||
|
value_query = (
|
||||||
|
db.query(
|
||||||
|
func.sum(Quote.monthly_total),
|
||||||
|
func.sum(Quote.setup_total)
|
||||||
|
)
|
||||||
|
.filter(Quote.status.in_(submitted_statuses))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
total_monthly_value = value_query[0] or Decimal("0.00")
|
||||||
|
total_setup_value = value_query[1] or Decimal("0.00")
|
||||||
|
|
||||||
|
# Quotes this month
|
||||||
|
month_start = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
quotes_this_month = (
|
||||||
|
db.query(Quote)
|
||||||
|
.filter(Quote.created_at >= month_start)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Quotes submitted this month
|
||||||
|
quotes_submitted_this_month = (
|
||||||
|
db.query(Quote)
|
||||||
|
.filter(
|
||||||
|
Quote.submitted_at >= month_start,
|
||||||
|
Quote.submitted_at.isnot(None)
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate averages and conversion rate
|
||||||
|
submitted_count = sum(
|
||||||
|
quotes_by_status.get(s, 0)
|
||||||
|
for s in submitted_statuses
|
||||||
|
)
|
||||||
|
|
||||||
|
average_monthly_value = (
|
||||||
|
total_monthly_value / submitted_count
|
||||||
|
if submitted_count > 0
|
||||||
|
else Decimal("0.00")
|
||||||
|
)
|
||||||
|
|
||||||
|
draft_count = quotes_by_status.get(QuoteStatus.DRAFT.value, 0)
|
||||||
|
total_started = draft_count + submitted_count
|
||||||
|
conversion_rate = (
|
||||||
|
(Decimal(submitted_count) / Decimal(total_started) * Decimal("100"))
|
||||||
|
if total_started > 0
|
||||||
|
else Decimal("0.00")
|
||||||
|
)
|
||||||
|
|
||||||
|
return QuoteStatsResponse(
|
||||||
|
total_quotes=total_quotes,
|
||||||
|
quotes_by_status=quotes_by_status,
|
||||||
|
total_monthly_value=total_monthly_value,
|
||||||
|
total_setup_value=total_setup_value,
|
||||||
|
quotes_this_month=quotes_this_month,
|
||||||
|
quotes_submitted_this_month=quotes_submitted_this_month,
|
||||||
|
average_monthly_value=round(average_monthly_value, 2),
|
||||||
|
conversion_rate=round(conversion_rate, 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_item_to_quote(
|
||||||
|
db: Session,
|
||||||
|
access_token: str,
|
||||||
|
item_data: QuoteItemCreate,
|
||||||
|
ip_address: Optional[str] = None
|
||||||
|
) -> Quote:
|
||||||
|
"""
|
||||||
|
Add a single item to a quote.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
access_token: The quote's access token
|
||||||
|
item_data: Item data to add
|
||||||
|
ip_address: IP address of the requester
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Quote: The updated quote object
|
||||||
|
"""
|
||||||
|
quote = get_quote_by_token(db, access_token)
|
||||||
|
|
||||||
|
if quote.status != QuoteStatus.DRAFT.value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot add items to a non-draft quote"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get next sort order
|
||||||
|
max_order = (
|
||||||
|
db.query(func.max(QuoteItem.sort_order))
|
||||||
|
.filter(QuoteItem.quote_id == quote.id)
|
||||||
|
.scalar()
|
||||||
|
) or 0
|
||||||
|
|
||||||
|
item = QuoteItem(
|
||||||
|
quote_id=quote.id,
|
||||||
|
service_name=item_data.service_name,
|
||||||
|
service_description=item_data.service_description,
|
||||||
|
category=item_data.category.value,
|
||||||
|
billing_frequency=item_data.billing_frequency.value,
|
||||||
|
unit_price=item_data.unit_price,
|
||||||
|
quantity=item_data.quantity,
|
||||||
|
setup_fee=item_data.setup_fee,
|
||||||
|
is_required=item_data.is_required,
|
||||||
|
sort_order=item_data.sort_order if item_data.sort_order else max_order + 1
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Recalculate totals
|
||||||
|
db.refresh(quote)
|
||||||
|
monthly, setup, annual = calculate_totals(quote.items)
|
||||||
|
quote.monthly_total = monthly
|
||||||
|
quote.setup_total = setup
|
||||||
|
quote.annual_total = annual
|
||||||
|
|
||||||
|
# Log activity
|
||||||
|
log_activity(
|
||||||
|
db=db,
|
||||||
|
quote_id=quote.id,
|
||||||
|
action="item_added",
|
||||||
|
description=f"Added item: {item_data.service_name}",
|
||||||
|
ip_address=ip_address
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(quote)
|
||||||
|
|
||||||
|
return quote
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to add item: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_item_from_quote(
|
||||||
|
db: Session,
|
||||||
|
access_token: str,
|
||||||
|
item_id: UUID,
|
||||||
|
ip_address: Optional[str] = None
|
||||||
|
) -> Quote:
|
||||||
|
"""
|
||||||
|
Remove an item from a quote.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
access_token: The quote's access token
|
||||||
|
item_id: UUID of the item to remove
|
||||||
|
ip_address: IP address of the requester
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Quote: The updated quote object
|
||||||
|
"""
|
||||||
|
quote = get_quote_by_token(db, access_token)
|
||||||
|
|
||||||
|
if quote.status != QuoteStatus.DRAFT.value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot remove items from a non-draft quote"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find the item
|
||||||
|
item = (
|
||||||
|
db.query(QuoteItem)
|
||||||
|
.filter(QuoteItem.id == str(item_id), QuoteItem.quote_id == quote.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Item with ID {item_id} not found in this quote"
|
||||||
|
)
|
||||||
|
|
||||||
|
if item.is_required:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot remove required items from the quote"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
item_name = item.service_name
|
||||||
|
db.delete(item)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Recalculate totals
|
||||||
|
db.refresh(quote)
|
||||||
|
monthly, setup, annual = calculate_totals(quote.items)
|
||||||
|
quote.monthly_total = monthly
|
||||||
|
quote.setup_total = setup
|
||||||
|
quote.annual_total = annual
|
||||||
|
|
||||||
|
# Log activity
|
||||||
|
log_activity(
|
||||||
|
db=db,
|
||||||
|
quote_id=quote.id,
|
||||||
|
action="item_removed",
|
||||||
|
description=f"Removed item: {item_name}",
|
||||||
|
ip_address=ip_address
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(quote)
|
||||||
|
|
||||||
|
return quote
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to remove item: {str(e)}"
|
||||||
|
)
|
||||||
445
api/services/syncro_service.py
Normal file
445
api/services/syncro_service.py
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
"""
|
||||||
|
SyncroRMM integration service for Quote Wizard.
|
||||||
|
|
||||||
|
This module handles all interactions with the SyncroRMM API for lead creation
|
||||||
|
and customer duplicate detection when quotes are submitted.
|
||||||
|
|
||||||
|
API Documentation: https://api-docs.syncromsp.com/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from api.models.quote import Quote
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# TODO: Move to environment variables or secure configuration for production
|
||||||
|
SYNCRO_API_BASE_URL = "https://computerguru.syncromsp.com/api/v1"
|
||||||
|
SYNCRO_API_KEY = "T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3"
|
||||||
|
|
||||||
|
# HTTP client configuration
|
||||||
|
SYNCRO_TIMEOUT_SECONDS = 30.0
|
||||||
|
SYNCRO_CONNECT_TIMEOUT_SECONDS = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CustomerCheckResult:
|
||||||
|
"""Result of checking for an existing customer in Syncro."""
|
||||||
|
exists: bool
|
||||||
|
customer_id: Optional[str] = None
|
||||||
|
customer_name: Optional[str] = None
|
||||||
|
match_type: Optional[str] = None # 'email' or 'business_name'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LeadCreationResult:
|
||||||
|
"""Result of creating a lead in Syncro."""
|
||||||
|
success: bool
|
||||||
|
lead_id: Optional[str] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SyncroService:
|
||||||
|
"""
|
||||||
|
Service for interacting with the SyncroRMM API.
|
||||||
|
|
||||||
|
Handles customer duplicate checking and lead creation for the Quote Wizard.
|
||||||
|
All API calls are made asynchronously to avoid blocking quote submission.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
syncro = SyncroService()
|
||||||
|
|
||||||
|
# Check for existing customer
|
||||||
|
result = await syncro.check_existing_customer(
|
||||||
|
email="contact@company.com",
|
||||||
|
business_name="Company Inc"
|
||||||
|
)
|
||||||
|
if result.exists:
|
||||||
|
print(f"Customer exists: {result.customer_name}")
|
||||||
|
|
||||||
|
# Create lead from quote
|
||||||
|
lead_result = await syncro.create_lead(quote)
|
||||||
|
if lead_result.success:
|
||||||
|
print(f"Lead created: {lead_result.lead_id}")
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_base_url: str = SYNCRO_API_BASE_URL,
|
||||||
|
api_key: str = SYNCRO_API_KEY,
|
||||||
|
timeout: float = SYNCRO_TIMEOUT_SECONDS,
|
||||||
|
connect_timeout: float = SYNCRO_CONNECT_TIMEOUT_SECONDS
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the SyncroService.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_base_url: Base URL for the Syncro API
|
||||||
|
api_key: API key for authentication
|
||||||
|
timeout: Total request timeout in seconds
|
||||||
|
connect_timeout: Connection timeout in seconds
|
||||||
|
"""
|
||||||
|
self.api_base_url = api_base_url.rstrip('/')
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = httpx.Timeout(timeout, connect=connect_timeout)
|
||||||
|
|
||||||
|
def _get_client(self) -> httpx.AsyncClient:
|
||||||
|
"""
|
||||||
|
Create an async HTTP client with configured settings.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
httpx.AsyncClient: Configured HTTP client
|
||||||
|
"""
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
timeout=self.timeout,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_url(self, endpoint: str, **params) -> str:
|
||||||
|
"""
|
||||||
|
Build a full API URL with the api_key parameter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint: API endpoint path (e.g., '/customers')
|
||||||
|
**params: Additional query parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Full URL with query parameters
|
||||||
|
"""
|
||||||
|
url = f"{self.api_base_url}{endpoint}"
|
||||||
|
query_params = {"api_key": self.api_key, **params}
|
||||||
|
|
||||||
|
# Build query string
|
||||||
|
query_string = "&".join(
|
||||||
|
f"{key}={httpx.URL('').copy_with(params={key: str(value)}).params[key]}"
|
||||||
|
for key, value in query_params.items()
|
||||||
|
if value is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"{url}?{query_string}"
|
||||||
|
|
||||||
|
async def check_existing_customer(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
business_name: Optional[str] = None
|
||||||
|
) -> CustomerCheckResult:
|
||||||
|
"""
|
||||||
|
Check if a customer already exists in Syncro.
|
||||||
|
|
||||||
|
Performs a two-stage check:
|
||||||
|
1. Search by email address (primary)
|
||||||
|
2. Search by business name if no email match (secondary)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: Contact email address to search for
|
||||||
|
business_name: Optional business name for secondary search
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CustomerCheckResult: Object containing match status and details
|
||||||
|
|
||||||
|
Note:
|
||||||
|
This method handles errors gracefully and returns a non-match
|
||||||
|
result if the API is unavailable, to avoid blocking quote submission.
|
||||||
|
"""
|
||||||
|
async with self._get_client() as client:
|
||||||
|
# First, check by email
|
||||||
|
try:
|
||||||
|
email_result = await self._search_customers_by_email(client, email)
|
||||||
|
if email_result.exists:
|
||||||
|
return email_result
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Syncro email search failed for {email}: {e}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
# Continue to business name search if email search fails
|
||||||
|
|
||||||
|
# If no email match, try business name
|
||||||
|
if business_name:
|
||||||
|
try:
|
||||||
|
name_result = await self._search_customers_by_business_name(
|
||||||
|
client, business_name
|
||||||
|
)
|
||||||
|
if name_result.exists:
|
||||||
|
return name_result
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Syncro business name search failed for {business_name}: {e}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# No matches found
|
||||||
|
return CustomerCheckResult(exists=False)
|
||||||
|
|
||||||
|
async def _search_customers_by_email(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
email: str
|
||||||
|
) -> CustomerCheckResult:
|
||||||
|
"""
|
||||||
|
Search for customers by email address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: HTTP client instance
|
||||||
|
email: Email address to search for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CustomerCheckResult: Match result
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPError: If the API request fails
|
||||||
|
"""
|
||||||
|
url = self._build_url("/customers", email=email)
|
||||||
|
|
||||||
|
response = await client.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
customers = data.get("customers", [])
|
||||||
|
|
||||||
|
if customers:
|
||||||
|
customer = customers[0]
|
||||||
|
return CustomerCheckResult(
|
||||||
|
exists=True,
|
||||||
|
customer_id=str(customer.get("id")),
|
||||||
|
customer_name=customer.get("business_name") or customer.get("fullname"),
|
||||||
|
match_type="email"
|
||||||
|
)
|
||||||
|
|
||||||
|
return CustomerCheckResult(exists=False)
|
||||||
|
|
||||||
|
async def _search_customers_by_business_name(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
business_name: str
|
||||||
|
) -> CustomerCheckResult:
|
||||||
|
"""
|
||||||
|
Search for customers by business name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: HTTP client instance
|
||||||
|
business_name: Business name to search for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CustomerCheckResult: Match result
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPError: If the API request fails
|
||||||
|
"""
|
||||||
|
url = self._build_url("/customers", business_name=business_name)
|
||||||
|
|
||||||
|
response = await client.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
customers = data.get("customers", [])
|
||||||
|
|
||||||
|
if customers:
|
||||||
|
# Look for exact match or very close match
|
||||||
|
normalized_search = business_name.lower().strip()
|
||||||
|
for customer in customers:
|
||||||
|
customer_name = customer.get("business_name", "").lower().strip()
|
||||||
|
if customer_name == normalized_search:
|
||||||
|
return CustomerCheckResult(
|
||||||
|
exists=True,
|
||||||
|
customer_id=str(customer.get("id")),
|
||||||
|
customer_name=customer.get("business_name"),
|
||||||
|
match_type="business_name"
|
||||||
|
)
|
||||||
|
|
||||||
|
return CustomerCheckResult(exists=False)
|
||||||
|
|
||||||
|
async def create_lead(self, quote: "Quote") -> LeadCreationResult:
|
||||||
|
"""
|
||||||
|
Create a lead in Syncro from a submitted quote.
|
||||||
|
|
||||||
|
Builds a formatted lead with quote details in the notes field.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
quote: Quote object with contact info and items
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LeadCreationResult: Object containing success status and lead ID
|
||||||
|
|
||||||
|
Note:
|
||||||
|
This method handles errors gracefully to avoid blocking quote
|
||||||
|
submission. Errors are logged but not raised.
|
||||||
|
"""
|
||||||
|
if not quote.contact_email:
|
||||||
|
return LeadCreationResult(
|
||||||
|
success=False,
|
||||||
|
error="Quote has no contact email"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse contact name into first/last
|
||||||
|
first_name, last_name = self._parse_contact_name(quote.contact_name or "")
|
||||||
|
|
||||||
|
# Build formatted notes with quote summary
|
||||||
|
notes = self._build_quote_summary(quote)
|
||||||
|
|
||||||
|
lead_data = {
|
||||||
|
"business_name": quote.company_name or "",
|
||||||
|
"first_name": first_name,
|
||||||
|
"last_name": last_name,
|
||||||
|
"email": quote.contact_email,
|
||||||
|
"phone": quote.contact_phone or "",
|
||||||
|
"address": "",
|
||||||
|
"referred_by": "Website Quote Tool",
|
||||||
|
"status": "New",
|
||||||
|
"notes": notes
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with self._get_client() as client:
|
||||||
|
url = self._build_url("/leads")
|
||||||
|
|
||||||
|
response = await client.post(url, json=lead_data)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
lead_id = str(data.get("lead", {}).get("id", ""))
|
||||||
|
|
||||||
|
if lead_id:
|
||||||
|
logger.info(
|
||||||
|
f"Created Syncro lead {lead_id} for quote {quote.id}"
|
||||||
|
)
|
||||||
|
return LeadCreationResult(success=True, lead_id=lead_id)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Syncro lead creation returned no ID for quote {quote.id}"
|
||||||
|
)
|
||||||
|
return LeadCreationResult(
|
||||||
|
success=False,
|
||||||
|
error="No lead ID returned from Syncro"
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
error_msg = f"Syncro API timeout: {e}"
|
||||||
|
logger.error(f"{error_msg} for quote {quote.id}")
|
||||||
|
return LeadCreationResult(success=False, error=error_msg)
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
error_msg = f"Syncro API error {e.response.status_code}: {e.response.text}"
|
||||||
|
logger.error(f"{error_msg} for quote {quote.id}")
|
||||||
|
return LeadCreationResult(success=False, error=error_msg)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Unexpected error: {str(e)}"
|
||||||
|
logger.error(f"{error_msg} for quote {quote.id}", exc_info=True)
|
||||||
|
return LeadCreationResult(success=False, error=error_msg)
|
||||||
|
|
||||||
|
def _parse_contact_name(self, full_name: str) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Parse a full name into first and last name components.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
full_name: Full contact name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (first_name, last_name)
|
||||||
|
"""
|
||||||
|
parts = full_name.strip().split(maxsplit=1)
|
||||||
|
|
||||||
|
if len(parts) == 0:
|
||||||
|
return ("", "")
|
||||||
|
elif len(parts) == 1:
|
||||||
|
return (parts[0], "")
|
||||||
|
else:
|
||||||
|
return (parts[0], parts[1])
|
||||||
|
|
||||||
|
def _build_quote_summary(self, quote: "Quote") -> str:
|
||||||
|
"""
|
||||||
|
Build formatted notes from quote items for Syncro lead.
|
||||||
|
|
||||||
|
Creates a human-readable summary of the quote including:
|
||||||
|
- Quote reference number
|
||||||
|
- Monthly and setup totals
|
||||||
|
- List of selected services with pricing
|
||||||
|
- Customer notes if provided
|
||||||
|
|
||||||
|
Args:
|
||||||
|
quote: Quote object with items
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Formatted notes string for Syncro lead
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Quote reference
|
||||||
|
access_token_short = quote.access_token[:8] if quote.access_token else "N/A"
|
||||||
|
lines.append(f"Quote #{access_token_short}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Totals
|
||||||
|
monthly_total = quote.monthly_total or Decimal("0.00")
|
||||||
|
setup_total = quote.setup_total or Decimal("0.00")
|
||||||
|
|
||||||
|
lines.append(f"Monthly: ${monthly_total:,.2f}")
|
||||||
|
if setup_total > 0:
|
||||||
|
lines.append(f"Setup: ${setup_total:,.2f}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Services
|
||||||
|
lines.append("Services:")
|
||||||
|
|
||||||
|
if hasattr(quote, 'items') and quote.items:
|
||||||
|
for item in sorted(quote.items, key=lambda x: x.sort_order or 0):
|
||||||
|
quantity = item.quantity or 1
|
||||||
|
unit_price = item.unit_price or Decimal("0.00")
|
||||||
|
line_total = quantity * unit_price
|
||||||
|
|
||||||
|
if quantity > 1:
|
||||||
|
lines.append(
|
||||||
|
f"- {item.service_name} ({quantity} x ${unit_price:,.2f}): "
|
||||||
|
f"${line_total:,.2f}/mo"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(f"- {item.service_name}: ${unit_price:,.2f}/mo")
|
||||||
|
|
||||||
|
# Add setup fee if present
|
||||||
|
if item.setup_fee and item.setup_fee > 0:
|
||||||
|
lines.append(f" Setup: ${item.setup_fee:,.2f}")
|
||||||
|
else:
|
||||||
|
lines.append("- No items")
|
||||||
|
|
||||||
|
# Employee count
|
||||||
|
if quote.employee_count:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Employees/Users: {quote.employee_count}")
|
||||||
|
|
||||||
|
# Customer notes
|
||||||
|
if quote.notes:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Customer Notes:")
|
||||||
|
lines.append(quote.notes)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance for convenience
|
||||||
|
_syncro_service: Optional[SyncroService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_syncro_service() -> SyncroService:
|
||||||
|
"""
|
||||||
|
Get or create the singleton SyncroService instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SyncroService: The service instance
|
||||||
|
"""
|
||||||
|
global _syncro_service
|
||||||
|
if _syncro_service is None:
|
||||||
|
_syncro_service = SyncroService()
|
||||||
|
return _syncro_service
|
||||||
156
migrations/versions/20260309_074038_msp_quote_wizard_tables.py
Normal file
156
migrations/versions/20260309_074038_msp_quote_wizard_tables.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""MSP Quote Wizard Tables
|
||||||
|
|
||||||
|
Revision ID: 20260309_074038
|
||||||
|
Revises: a0dfb0b4373c
|
||||||
|
Create Date: 2026-03-09 07:40:38
|
||||||
|
|
||||||
|
Creates the MSP Quote Wizard tables:
|
||||||
|
- quotes: Main quote records with contact info, pricing, and tracking
|
||||||
|
- quote_items: Line items for each quote (services, products, addons)
|
||||||
|
- quote_activity: Activity log for quote interactions
|
||||||
|
- quote_notifications: Email/webhook notification queue
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '20260309_074038'
|
||||||
|
down_revision: Union[str, None] = 'a0dfb0b4373c'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create MSP Quote Wizard tables."""
|
||||||
|
|
||||||
|
# 1. Create quotes table - main quote records
|
||||||
|
op.create_table(
|
||||||
|
'quotes',
|
||||||
|
sa.Column('id', sa.CHAR(36), primary_key=True),
|
||||||
|
sa.Column('company_name', sa.String(255), nullable=True),
|
||||||
|
sa.Column('contact_name', sa.String(255), nullable=False),
|
||||||
|
sa.Column('contact_email', sa.String(255), nullable=False),
|
||||||
|
sa.Column('contact_phone', sa.String(50), nullable=True),
|
||||||
|
sa.Column('employee_count', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('industry', sa.String(100), nullable=True),
|
||||||
|
sa.Column('current_it_situation', sa.Text(), nullable=True),
|
||||||
|
sa.Column('status', sa.Enum('draft', 'submitted', 'viewed', 'followed_up', 'converted', 'expired', name='quote_status'), server_default='draft'),
|
||||||
|
sa.Column('access_token', sa.String(64), unique=True, nullable=False),
|
||||||
|
sa.Column('monthly_total', sa.DECIMAL(10, 2), server_default='0'),
|
||||||
|
sa.Column('setup_total', sa.DECIMAL(10, 2), server_default='0'),
|
||||||
|
sa.Column('syncro_lead_id', sa.String(100), nullable=True),
|
||||||
|
sa.Column('syncro_synced_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('is_existing_customer', sa.Boolean(), server_default='0'),
|
||||||
|
sa.Column('source', sa.String(50), server_default='website'),
|
||||||
|
sa.Column('utm_source', sa.String(100), nullable=True),
|
||||||
|
sa.Column('utm_medium', sa.String(100), nullable=True),
|
||||||
|
sa.Column('utm_campaign', sa.String(100), nullable=True),
|
||||||
|
sa.Column('ip_address', sa.String(45), nullable=True),
|
||||||
|
sa.Column('user_agent', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')),
|
||||||
|
sa.Column('submitted_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indexes for quotes table
|
||||||
|
op.create_index('idx_quotes_status', 'quotes', ['status'])
|
||||||
|
op.create_index('idx_quotes_email', 'quotes', ['contact_email'])
|
||||||
|
op.create_index('idx_quotes_created', 'quotes', ['created_at'])
|
||||||
|
op.create_index('idx_quotes_token', 'quotes', ['access_token'])
|
||||||
|
|
||||||
|
# 2. Create quote_items table - line items for each quote
|
||||||
|
op.create_table(
|
||||||
|
'quote_items',
|
||||||
|
sa.Column('id', sa.CHAR(36), primary_key=True),
|
||||||
|
sa.Column('quote_id', sa.CHAR(36), nullable=False),
|
||||||
|
sa.Column('category', sa.Enum('gps_monitoring', 'support_plan', 'voip', 'web_hosting', 'email', 'hardware', 'addon', name='quote_item_category'), nullable=False),
|
||||||
|
sa.Column('product_code', sa.String(50), nullable=False),
|
||||||
|
sa.Column('product_name', sa.String(255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('quantity', sa.Integer(), server_default='1'),
|
||||||
|
sa.Column('unit_price', sa.DECIMAL(10, 2), nullable=False),
|
||||||
|
sa.Column('setup_price', sa.DECIMAL(10, 2), server_default='0'),
|
||||||
|
sa.Column('billing_frequency', sa.Enum('monthly', 'yearly', 'one_time', name='billing_frequency'), server_default='monthly'),
|
||||||
|
sa.Column('tier', sa.String(50), nullable=True),
|
||||||
|
sa.Column('is_recommended', sa.Boolean(), server_default='0'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||||
|
sa.ForeignKeyConstraint(['quote_id'], ['quotes.id'], ondelete='CASCADE'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indexes for quote_items table
|
||||||
|
op.create_index('idx_quote_items_quote', 'quote_items', ['quote_id'])
|
||||||
|
op.create_index('idx_quote_items_category', 'quote_items', ['category'])
|
||||||
|
|
||||||
|
# 3. Create quote_activity table - activity log for quotes
|
||||||
|
op.create_table(
|
||||||
|
'quote_activity',
|
||||||
|
sa.Column('id', sa.CHAR(36), primary_key=True),
|
||||||
|
sa.Column('quote_id', sa.CHAR(36), nullable=False),
|
||||||
|
sa.Column('action', sa.String(50), nullable=False),
|
||||||
|
sa.Column('step_name', sa.String(50), nullable=True),
|
||||||
|
sa.Column('details', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('ip_address', sa.String(45), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||||
|
sa.ForeignKeyConstraint(['quote_id'], ['quotes.id'], ondelete='CASCADE'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Index for quote_activity table
|
||||||
|
op.create_index('idx_quote_activity_quote', 'quote_activity', ['quote_id'])
|
||||||
|
|
||||||
|
# 4. Create quote_notifications table - notification queue
|
||||||
|
op.create_table(
|
||||||
|
'quote_notifications',
|
||||||
|
sa.Column('id', sa.CHAR(36), primary_key=True),
|
||||||
|
sa.Column('quote_id', sa.CHAR(36), nullable=False),
|
||||||
|
sa.Column('notification_type', sa.Enum('email', 'webhook', name='notification_type'), nullable=False),
|
||||||
|
sa.Column('recipient', sa.String(255), nullable=False),
|
||||||
|
sa.Column('subject', sa.String(255), nullable=True),
|
||||||
|
sa.Column('body', sa.Text(), nullable=True),
|
||||||
|
sa.Column('status', sa.Enum('pending', 'sent', 'failed', name='notification_status'), server_default='pending'),
|
||||||
|
sa.Column('attempts', sa.Integer(), server_default='0'),
|
||||||
|
sa.Column('last_attempt_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('sent_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('error_message', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||||
|
sa.ForeignKeyConstraint(['quote_id'], ['quotes.id'], ondelete='CASCADE'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indexes for quote_notifications table
|
||||||
|
op.create_index('idx_notifications_status', 'quote_notifications', ['status'])
|
||||||
|
op.create_index('idx_notifications_quote', 'quote_notifications', ['quote_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop MSP Quote Wizard tables in reverse order."""
|
||||||
|
|
||||||
|
# Drop quote_notifications and its indexes
|
||||||
|
op.drop_index('idx_notifications_quote', table_name='quote_notifications')
|
||||||
|
op.drop_index('idx_notifications_status', table_name='quote_notifications')
|
||||||
|
op.drop_table('quote_notifications')
|
||||||
|
|
||||||
|
# Drop quote_activity and its index
|
||||||
|
op.drop_index('idx_quote_activity_quote', table_name='quote_activity')
|
||||||
|
op.drop_table('quote_activity')
|
||||||
|
|
||||||
|
# Drop quote_items and its indexes
|
||||||
|
op.drop_index('idx_quote_items_category', table_name='quote_items')
|
||||||
|
op.drop_index('idx_quote_items_quote', table_name='quote_items')
|
||||||
|
op.drop_table('quote_items')
|
||||||
|
|
||||||
|
# Drop quotes and its indexes
|
||||||
|
op.drop_index('idx_quotes_token', table_name='quotes')
|
||||||
|
op.drop_index('idx_quotes_created', table_name='quotes')
|
||||||
|
op.drop_index('idx_quotes_email', table_name='quotes')
|
||||||
|
op.drop_index('idx_quotes_status', table_name='quotes')
|
||||||
|
op.drop_table('quotes')
|
||||||
|
|
||||||
|
# Drop the enum types
|
||||||
|
op.execute("DROP TYPE IF EXISTS notification_status")
|
||||||
|
op.execute("DROP TYPE IF EXISTS notification_type")
|
||||||
|
op.execute("DROP TYPE IF EXISTS billing_frequency")
|
||||||
|
op.execute("DROP TYPE IF EXISTS quote_item_category")
|
||||||
|
op.execute("DROP TYPE IF EXISTS quote_status")
|
||||||
24
projects/msp-tools/quote-wizard/frontend/.gitignore
vendored
Normal file
24
projects/msp-tools/quote-wizard/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
projects/msp-tools/quote-wizard/frontend/README.md
Normal file
73
projects/msp-tools/quote-wizard/frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
projects/msp-tools/quote-wizard/frontend/eslint.config.js
Normal file
23
projects/msp-tools/quote-wizard/frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
14
projects/msp-tools/quote-wizard/frontend/index.html
Normal file
14
projects/msp-tools/quote-wizard/frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📊</text></svg>" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="MSP Quote Wizard - Get a custom IT services quote for your business" />
|
||||||
|
<title>MSP Quote Wizard | AZ Computer Guru</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4180
projects/msp-tools/quote-wizard/frontend/package-lock.json
generated
Normal file
4180
projects/msp-tools/quote-wizard/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
projects/msp-tools/quote-wizard/frontend/package.json
Normal file
37
projects/msp-tools/quote-wizard/frontend/package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "msp-quote-wizard",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"axios": "^1.13.6",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.35.2",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"tailwindcss": "^4.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.48.0",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
projects/msp-tools/quote-wizard/frontend/src/App.tsx
Normal file
26
projects/msp-tools/quote-wizard/frontend/src/App.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { WizardContainer } from '@/components/wizard/WizardContainer'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white">
|
||||||
|
<header className="bg-[#333d49] text-white py-4 px-6">
|
||||||
|
<div className="max-w-6xl mx-auto flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">MSP Quote Wizard</h1>
|
||||||
|
<span className="text-sm text-gray-300">Powered by AZ Computer Guru</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="py-8">
|
||||||
|
<WizardContainer />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="bg-[#113559] text-white py-6 px-6 mt-auto">
|
||||||
|
<div className="max-w-6xl mx-auto text-center text-sm">
|
||||||
|
<p>© {new Date().getFullYear()} AZ Computer Guru. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { ChevronDown, HelpCircle } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface ExpandableInfoProps {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpandableInfo({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
defaultExpanded = false,
|
||||||
|
icon,
|
||||||
|
className,
|
||||||
|
}: ExpandableInfoProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('border border-gray-200 rounded-lg overflow-hidden', className)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 transition-colors"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{icon || <HelpCircle className="w-5 h-5 text-[#fe7400]" />}
|
||||||
|
<span className="font-medium text-[#333d49]">{title}</span>
|
||||||
|
</div>
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||||
|
</motion.div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="px-4 pb-4 pt-0 text-sm text-gray-600 border-t border-gray-100">
|
||||||
|
<div className="pt-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
import { Card, Button } from '@/components/ui';
|
||||||
|
import { cn, formatCurrency } from '@/lib/utils';
|
||||||
|
import type { PricingTier } from '@/types/quote';
|
||||||
|
|
||||||
|
export interface PricingCardProps {
|
||||||
|
tier: PricingTier;
|
||||||
|
isSelected: boolean;
|
||||||
|
deviceCount: number;
|
||||||
|
onSelect: (tierId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PricingCard({ tier, isSelected, deviceCount, onSelect }: PricingCardProps) {
|
||||||
|
const monthlyEstimate = tier.basePrice + tier.perDevicePrice * deviceCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ y: -4 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
|
||||||
|
padding="none"
|
||||||
|
className={cn(
|
||||||
|
'relative overflow-hidden',
|
||||||
|
tier.recommended && !isSelected && 'ring-2 ring-[#333d49]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Recommended badge */}
|
||||||
|
{tier.recommended && (
|
||||||
|
<div className="absolute top-0 right-0">
|
||||||
|
<div className="bg-[#fe7400] text-white text-xs font-semibold px-3 py-1 rounded-bl-lg">
|
||||||
|
Recommended
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-xl font-semibold text-[#333d49]">{tier.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{tier.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-3xl font-bold text-[#333d49]">
|
||||||
|
{formatCurrency(monthlyEstimate)}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500">/month</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{formatCurrency(tier.basePrice)} base + {formatCurrency(tier.perDevicePrice)}/device
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<ul className="space-y-2 mb-6">
|
||||||
|
{tier.features.map((feature, index) => (
|
||||||
|
<li key={index} className="flex items-start gap-2 text-sm">
|
||||||
|
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-gray-600">{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Select button */}
|
||||||
|
<Button
|
||||||
|
variant={isSelected ? 'primary' : 'outline'}
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => onSelect(tier.id)}
|
||||||
|
>
|
||||||
|
{isSelected ? 'Selected' : 'Select Plan'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { Check, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { PricingTier } from '@/types/quote';
|
||||||
|
|
||||||
|
export interface TierComparisonProps {
|
||||||
|
tiers: PricingTier[];
|
||||||
|
selectedTier?: string;
|
||||||
|
onSelectTier: (tierId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeatureRow {
|
||||||
|
name: string;
|
||||||
|
essential: boolean | string;
|
||||||
|
professional: boolean | string;
|
||||||
|
enterprise: boolean | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparisonFeatures: FeatureRow[] = [
|
||||||
|
{ name: 'Remote Monitoring', essential: true, professional: true, enterprise: true },
|
||||||
|
{ name: 'Help Desk Support', essential: '8x5', professional: '24x7', enterprise: '24x7 Priority' },
|
||||||
|
{ name: 'Patch Management', essential: true, professional: true, enterprise: true },
|
||||||
|
{ name: 'Antivirus Protection', essential: 'Basic', professional: 'Advanced', enterprise: 'Advanced' },
|
||||||
|
{ name: 'Backup & Recovery', essential: false, professional: true, enterprise: true },
|
||||||
|
{ name: 'Network Monitoring', essential: false, professional: true, enterprise: true },
|
||||||
|
{ name: 'On-Site Support', essential: false, professional: 'Limited', enterprise: 'Unlimited' },
|
||||||
|
{ name: 'Vendor Management', essential: false, professional: true, enterprise: true },
|
||||||
|
{ name: 'Dedicated Account Manager', essential: false, professional: false, enterprise: true },
|
||||||
|
{ name: 'Virtual CIO Services', essential: false, professional: false, enterprise: true },
|
||||||
|
{ name: 'Compliance Management', essential: false, professional: false, enterprise: true },
|
||||||
|
{ name: 'Security Training', essential: false, professional: false, enterprise: true },
|
||||||
|
{ name: 'Business Reviews', essential: 'Annual', professional: 'Quarterly', enterprise: 'Monthly' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TierComparison({ tiers, selectedTier, onSelectTier }: TierComparisonProps) {
|
||||||
|
const renderCell = (value: boolean | string) => {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value ? (
|
||||||
|
<Check className="w-5 h-5 text-green-500 mx-auto" />
|
||||||
|
) : (
|
||||||
|
<X className="w-5 h-5 text-gray-300 mx-auto" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="text-sm text-[#333d49]">{value}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-4 border-b border-gray-200 bg-gray-50">
|
||||||
|
<span className="font-semibold text-[#333d49]">Feature</span>
|
||||||
|
</th>
|
||||||
|
{tiers.map((tier) => (
|
||||||
|
<th
|
||||||
|
key={tier.id}
|
||||||
|
className={cn(
|
||||||
|
'p-4 border-b border-gray-200 text-center cursor-pointer transition-colors',
|
||||||
|
selectedTier === tier.id
|
||||||
|
? 'bg-[#fe7400]/10'
|
||||||
|
: 'bg-gray-50 hover:bg-gray-100'
|
||||||
|
)}
|
||||||
|
onClick={() => onSelectTier(tier.id)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'font-semibold',
|
||||||
|
selectedTier === tier.id ? 'text-[#fe7400]' : 'text-[#333d49]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tier.name}
|
||||||
|
</span>
|
||||||
|
{tier.recommended && (
|
||||||
|
<span className="block text-xs text-[#fe7400] mt-1">Recommended</span>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{comparisonFeatures.map((feature, index) => (
|
||||||
|
<tr key={feature.name} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50/50'}>
|
||||||
|
<td className="p-4 border-b border-gray-100 text-sm text-gray-600">
|
||||||
|
{feature.name}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
'p-4 border-b border-gray-100 text-center',
|
||||||
|
selectedTier === 'essential' && 'bg-[#fe7400]/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderCell(feature.essential)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
'p-4 border-b border-gray-100 text-center',
|
||||||
|
selectedTier === 'professional' && 'bg-[#fe7400]/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderCell(feature.professional)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
'p-4 border-b border-gray-100 text-center',
|
||||||
|
selectedTier === 'enterprise' && 'bg-[#fe7400]/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderCell(feature.enterprise)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { PricingCard, type PricingCardProps } from './PricingCard';
|
||||||
|
export { ExpandableInfo, type ExpandableInfoProps } from './ExpandableInfo';
|
||||||
|
export { TierComparison, type TierComparisonProps } from './TierComparison';
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onDrag' | 'onDragStart' | 'onDragEnd' | 'onAnimationStart'> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
isLoading = false,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const baseStyles =
|
||||||
|
'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary:
|
||||||
|
'bg-[#fe7400] text-white hover:bg-[#e56800] focus-visible:ring-[#fe7400] shadow-sm hover:shadow-md',
|
||||||
|
secondary:
|
||||||
|
'bg-[#333d49] text-white hover:bg-[#252d36] focus-visible:ring-[#333d49] shadow-sm hover:shadow-md',
|
||||||
|
outline:
|
||||||
|
'border-2 border-[#333d49] text-[#333d49] hover:bg-[#333d49] hover:text-white focus-visible:ring-[#333d49]',
|
||||||
|
ghost:
|
||||||
|
'text-[#333d49] hover:bg-gray-100 focus-visible:ring-[#333d49]',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'px-3 py-1.5 text-sm',
|
||||||
|
md: 'px-5 py-2.5 text-base',
|
||||||
|
lg: 'px-7 py-3.5 text-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
ref={ref}
|
||||||
|
whileHover={{ scale: disabled || isLoading ? 1 : 1.02 }}
|
||||||
|
whileTap={{ scale: disabled || isLoading ? 1 : 0.98 }}
|
||||||
|
className={cn(baseStyles, variants[variant], sizes[size], className)}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button };
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface CardProps {
|
||||||
|
variant?: 'default' | 'elevated' | 'outlined' | 'highlighted';
|
||||||
|
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||||
|
hoverable?: boolean;
|
||||||
|
className?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
padding = 'md',
|
||||||
|
hoverable = false,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const baseStyles = 'rounded-xl transition-all duration-200';
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
default: 'bg-white border border-gray-200',
|
||||||
|
elevated: 'bg-white shadow-lg',
|
||||||
|
outlined: 'bg-transparent border-2 border-[#333d49]',
|
||||||
|
highlighted: 'bg-white border-2 border-[#fe7400] shadow-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
const paddings = {
|
||||||
|
none: '',
|
||||||
|
sm: 'p-4',
|
||||||
|
md: 'p-6',
|
||||||
|
lg: 'p-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
const hoverStyles = hoverable
|
||||||
|
? 'cursor-pointer hover:shadow-xl hover:-translate-y-1'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (hoverable) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
whileHover={{ scale: 1.01 }}
|
||||||
|
whileTap={{ scale: 0.99 }}
|
||||||
|
className={cn(
|
||||||
|
baseStyles,
|
||||||
|
variants[variant],
|
||||||
|
paddings[padding],
|
||||||
|
hoverStyles,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
baseStyles,
|
||||||
|
variants[variant],
|
||||||
|
paddings[padding],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
// Card subcomponents
|
||||||
|
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('mb-4 pb-4 border-b border-gray-100', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-xl font-semibold text-[#333d49]', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-gray-500 mt-1', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('mt-4 pt-4 border-t border-gray-100 flex items-center', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { forwardRef, type InputHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
helperText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, label, error, helperText, id, type = 'text', ...props }, ref) => {
|
||||||
|
const inputId = id || `input-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className="block text-sm font-medium text-[#333d49] mb-1.5"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type={type}
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-4 py-2.5 rounded-lg border transition-all duration-200',
|
||||||
|
'text-[#333d49] placeholder-gray-400',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-offset-0',
|
||||||
|
error
|
||||||
|
? 'border-red-500 focus:border-red-500 focus:ring-red-200'
|
||||||
|
: 'border-gray-300 focus:border-[#fe7400] focus:ring-[#fe7400]/20',
|
||||||
|
'disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-invalid={error ? 'true' : 'false'}
|
||||||
|
aria-describedby={
|
||||||
|
error ? `${inputId}-error` : helperText ? `${inputId}-helper` : undefined
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-red-500">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{helperText && !error && (
|
||||||
|
<p id={`${inputId}-helper`} className="mt-1.5 text-sm text-gray-500">
|
||||||
|
{helperText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface ProgressBarProps {
|
||||||
|
progress: number;
|
||||||
|
showLabel?: boolean;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
variant?: 'default' | 'accent';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBar({
|
||||||
|
progress,
|
||||||
|
showLabel = false,
|
||||||
|
size = 'md',
|
||||||
|
variant = 'accent',
|
||||||
|
className,
|
||||||
|
}: ProgressBarProps) {
|
||||||
|
const clampedProgress = Math.min(100, Math.max(0, progress));
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'h-1.5',
|
||||||
|
md: 'h-2.5',
|
||||||
|
lg: 'h-4',
|
||||||
|
};
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
default: 'bg-[#333d49]',
|
||||||
|
accent: 'bg-[#fe7400]',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full', className)}>
|
||||||
|
{showLabel && (
|
||||||
|
<div className="flex justify-between items-center mb-1.5">
|
||||||
|
<span className="text-sm font-medium text-[#333d49]">Progress</span>
|
||||||
|
<span className="text-sm font-medium text-[#333d49]">{clampedProgress}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn('w-full bg-gray-200 rounded-full overflow-hidden', sizes[size])}
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={clampedProgress}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className={cn('h-full rounded-full', variants[variant])}
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${clampedProgress}%` }}
|
||||||
|
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export { Button, type ButtonProps } from './Button';
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
type CardProps,
|
||||||
|
} from './Card';
|
||||||
|
export { Input, type InputProps } from './Input';
|
||||||
|
export { ProgressBar, type ProgressBarProps } from './ProgressBar';
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Card, CardContent } from '@/components/ui';
|
||||||
|
import { WizardProgress } from './WizardProgress';
|
||||||
|
import { WizardNavigation } from './WizardNavigation';
|
||||||
|
import { useWizard } from '@/hooks/useWizard';
|
||||||
|
import { useQuote } from '@/hooks/useQuote';
|
||||||
|
import {
|
||||||
|
Step1CompanyProfile,
|
||||||
|
Step2GPSMonitoring,
|
||||||
|
Step3SupportPlan,
|
||||||
|
Step4VoIP,
|
||||||
|
Step5WebEmail,
|
||||||
|
Step6Summary,
|
||||||
|
Step7Contact,
|
||||||
|
} from './steps';
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Monitor,
|
||||||
|
Headphones,
|
||||||
|
Phone,
|
||||||
|
Globe,
|
||||||
|
FileCheck,
|
||||||
|
Send,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { formatCurrency } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WizardContainer - Main container for the MSP Quote Wizard
|
||||||
|
*
|
||||||
|
* Orchestrates the 7-step wizard flow:
|
||||||
|
* 1. Company Profile
|
||||||
|
* 2. GPS Monitoring
|
||||||
|
* 3. Support Plan
|
||||||
|
* 4. VoIP Phone System
|
||||||
|
* 5. Web & Email
|
||||||
|
* 6. Review Quote
|
||||||
|
* 7. Contact & Submit
|
||||||
|
*/
|
||||||
|
|
||||||
|
const stepIcons = [Building2, Monitor, Headphones, Phone, Globe, FileCheck, Send];
|
||||||
|
|
||||||
|
export function WizardContainer() {
|
||||||
|
const wizard = useWizard();
|
||||||
|
const quote = useQuote();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [submitSuccess, setSubmitSuccess] = useState(false);
|
||||||
|
|
||||||
|
const StepIcon = stepIcons[wizard.currentStep] || Building2;
|
||||||
|
const currentStepData = wizard.steps[wizard.currentStep];
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
// Calculate quote before moving to summary
|
||||||
|
if (wizard.currentStep === 4) {
|
||||||
|
quote.calculateQuote();
|
||||||
|
}
|
||||||
|
wizard.nextStep();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
wizard.prevStep();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
// Calculate final quote
|
||||||
|
const result = quote.calculateQuote();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Simulate API submission
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
|
// Log submission (in production, this would send to an API)
|
||||||
|
console.log('Quote submitted:', {
|
||||||
|
quoteData: quote.quoteData,
|
||||||
|
quoteResult: result,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
setSubmitSuccess(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Submission error:', error);
|
||||||
|
// Handle error state here
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoToStep = (step: number) => {
|
||||||
|
wizard.goToStep(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate current step for "Next" button
|
||||||
|
const isNextDisabled = (): boolean => {
|
||||||
|
switch (wizard.currentStep) {
|
||||||
|
case 0: // Company Profile
|
||||||
|
return quote.quoteData.company.endpointCount < 1;
|
||||||
|
case 6: // Contact
|
||||||
|
return (
|
||||||
|
!quote.quoteData.contact.name.trim() ||
|
||||||
|
!quote.quoteData.contact.email.trim() ||
|
||||||
|
!quote.quoteData.contact.agreedToTerms
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render current step content
|
||||||
|
const renderStepContent = () => {
|
||||||
|
switch (wizard.currentStep) {
|
||||||
|
case 0:
|
||||||
|
return (
|
||||||
|
<Step1CompanyProfile
|
||||||
|
companyInfo={quote.quoteData.company}
|
||||||
|
onUpdateCompany={quote.updateCompany}
|
||||||
|
onSetEndpointCount={quote.setEndpointCount}
|
||||||
|
onSetIndustry={quote.setIndustry}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 1:
|
||||||
|
return (
|
||||||
|
<Step2GPSMonitoring
|
||||||
|
gpsSelection={quote.quoteData.gps}
|
||||||
|
onSetGPSTier={quote.setGPSTier}
|
||||||
|
onSetEquipmentEnabled={quote.setEquipmentEnabled}
|
||||||
|
onSetEquipmentCount={quote.setEquipmentCount}
|
||||||
|
getGPSMonthly={quote.getGPSMonthly}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<Step3SupportPlan
|
||||||
|
supportSelection={quote.quoteData.support}
|
||||||
|
endpointCount={quote.quoteData.company.endpointCount}
|
||||||
|
onSetSupportPlan={quote.setSupportPlan}
|
||||||
|
onSetBlockTimeEnabled={quote.setBlockTimeEnabled}
|
||||||
|
onSetBlockTime={quote.setBlockTime}
|
||||||
|
getSupportMonthly={quote.getSupportMonthly}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 3:
|
||||||
|
return (
|
||||||
|
<Step4VoIP
|
||||||
|
voipSelection={quote.quoteData.voip}
|
||||||
|
onSetVoIPEnabled={quote.setVoIPEnabled}
|
||||||
|
onSetVoIPTier={quote.setVoIPTier}
|
||||||
|
onSetVoIPUserCount={quote.setVoIPUserCount}
|
||||||
|
onAddHardware={quote.addHardware}
|
||||||
|
onRemoveHardware={quote.removeHardware}
|
||||||
|
onUpdateHardwareQuantity={quote.updateHardwareQuantity}
|
||||||
|
getVoIPMonthly={quote.getVoIPMonthly}
|
||||||
|
getVoIPOneTime={quote.getVoIPOneTime}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 4:
|
||||||
|
return (
|
||||||
|
<Step5WebEmail
|
||||||
|
webHostingSelection={quote.quoteData.webHosting}
|
||||||
|
emailSelection={quote.quoteData.email}
|
||||||
|
onSetWebHostingEnabled={quote.setWebHostingEnabled}
|
||||||
|
onSetWebHostingTier={quote.setWebHostingTier}
|
||||||
|
onSetEmailEnabled={quote.setEmailEnabled}
|
||||||
|
onSetEmailProvider={quote.setEmailProvider}
|
||||||
|
onSetEmailTier={quote.setEmailTier}
|
||||||
|
onSetMailboxCount={quote.setMailboxCount}
|
||||||
|
getWebHostingMonthly={quote.getWebHostingMonthly}
|
||||||
|
getEmailMonthly={quote.getEmailMonthly}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 5:
|
||||||
|
return (
|
||||||
|
<Step6Summary
|
||||||
|
quoteData={quote.quoteData}
|
||||||
|
quoteResult={quote.quoteResult}
|
||||||
|
onGoToStep={handleGoToStep}
|
||||||
|
onCalculateQuote={quote.calculateQuote}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 6:
|
||||||
|
return (
|
||||||
|
<Step7Contact
|
||||||
|
contactInfo={quote.quoteData.contact}
|
||||||
|
companyNameFromStep1={quote.quoteData.company.name}
|
||||||
|
quoteResult={quote.quoteResult}
|
||||||
|
onUpdateContact={quote.updateContact}
|
||||||
|
onSetContactPreference={quote.setContactPreference}
|
||||||
|
onSetAgreedToTerms={quote.setAgreedToTerms}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Success state
|
||||||
|
if (submitSuccess) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6">
|
||||||
|
<Card variant="elevated" padding="lg">
|
||||||
|
<CardContent>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="text-center py-12"
|
||||||
|
>
|
||||||
|
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<svg
|
||||||
|
className="w-10 h-10 text-green-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold text-[#333d49] mb-4">
|
||||||
|
Quote Request Submitted!
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mb-8 max-w-md mx-auto">
|
||||||
|
Thank you for your interest. Our team will review your quote and
|
||||||
|
contact you within 24 hours.
|
||||||
|
</p>
|
||||||
|
{quote.quoteResult && (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-6 max-w-sm mx-auto mb-8">
|
||||||
|
<p className="text-sm text-gray-500 mb-2">Your Estimated Monthly Total</p>
|
||||||
|
<p className="text-4xl font-bold text-[#fe7400]">
|
||||||
|
{formatCurrency(quote.quoteResult.monthlyTotal)}
|
||||||
|
<span className="text-lg font-normal text-gray-500">/mo</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
quote.resetQuote();
|
||||||
|
wizard.resetWizard();
|
||||||
|
setSubmitSuccess(false);
|
||||||
|
}}
|
||||||
|
className="text-[#fe7400] hover:text-[#e56800] font-medium"
|
||||||
|
>
|
||||||
|
Start a New Quote
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6">
|
||||||
|
{/* Progress indicator */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<WizardProgress
|
||||||
|
steps={wizard.steps}
|
||||||
|
currentStep={wizard.currentStep}
|
||||||
|
onStepClick={wizard.goToStep}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main wizard card */}
|
||||||
|
<Card variant="elevated" padding="lg">
|
||||||
|
<CardContent>
|
||||||
|
{/* Step header */}
|
||||||
|
<div className="flex items-center gap-4 mb-6 pb-6 border-b border-gray-100">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-[#fe7400]/10">
|
||||||
|
<StepIcon className="w-6 h-6 text-[#fe7400]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-[#333d49]">
|
||||||
|
{currentStepData?.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-500">{currentStepData?.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step content with animation */}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={wizard.currentStep}
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="min-h-[400px]"
|
||||||
|
>
|
||||||
|
{renderStepContent()}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Navigation - hidden on contact step (has its own submit) */}
|
||||||
|
{wizard.currentStep !== 6 && (
|
||||||
|
<WizardNavigation
|
||||||
|
onNext={handleNext}
|
||||||
|
onPrev={handlePrev}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isFirstStep={wizard.isFirstStep}
|
||||||
|
isLastStep={wizard.isLastStep}
|
||||||
|
isNextDisabled={isNextDisabled()}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Quick stats - show running total */}
|
||||||
|
<div className="mt-6 grid grid-cols-3 gap-4">
|
||||||
|
<Card variant="default" padding="sm" className="text-center">
|
||||||
|
<p className="text-sm text-gray-500">Endpoints</p>
|
||||||
|
<p className="text-2xl font-bold text-[#333d49]">
|
||||||
|
{quote.quoteData.company.endpointCount}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
<Card variant="default" padding="sm" className="text-center">
|
||||||
|
<p className="text-sm text-gray-500">Est. Monthly</p>
|
||||||
|
<p className="text-2xl font-bold text-[#fe7400]">
|
||||||
|
{formatCurrency(
|
||||||
|
quote.getGPSMonthly() +
|
||||||
|
quote.getSupportMonthly() +
|
||||||
|
quote.getVoIPMonthly() +
|
||||||
|
quote.getWebHostingMonthly() +
|
||||||
|
quote.getEmailMonthly()
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
<Card variant="default" padding="sm" className="text-center">
|
||||||
|
<p className="text-sm text-gray-500">Progress</p>
|
||||||
|
<p className="text-2xl font-bold text-[#333d49]">{wizard.progress}%</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui';
|
||||||
|
|
||||||
|
export interface WizardNavigationProps {
|
||||||
|
onNext: () => void;
|
||||||
|
onPrev: () => void;
|
||||||
|
onSubmit?: () => void;
|
||||||
|
isFirstStep: boolean;
|
||||||
|
isLastStep: boolean;
|
||||||
|
isNextDisabled?: boolean;
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WizardNavigation({
|
||||||
|
onNext,
|
||||||
|
onPrev,
|
||||||
|
onSubmit,
|
||||||
|
isFirstStep,
|
||||||
|
isLastStep,
|
||||||
|
isNextDisabled = false,
|
||||||
|
isSubmitting = false,
|
||||||
|
}: WizardNavigationProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between pt-6 mt-6 border-t border-gray-200">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onPrev}
|
||||||
|
disabled={isFirstStep}
|
||||||
|
className={isFirstStep ? 'invisible' : ''}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isLastStep ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
onClick={onSubmit}
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
disabled={isNextDisabled || isSubmitting}
|
||||||
|
>
|
||||||
|
Get My Quote
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={isNextDisabled}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="w-4 h-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
import type { WizardStep } from '@/types/quote';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface WizardProgressProps {
|
||||||
|
steps: WizardStep[];
|
||||||
|
currentStep: number;
|
||||||
|
onStepClick?: (stepIndex: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WizardProgress({ steps, currentStep, onStepClick }: WizardProgressProps) {
|
||||||
|
const isCompactMode = steps.length > 5;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Progress" className="w-full">
|
||||||
|
<ol className="flex items-center justify-between">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const isCompleted = step.isComplete;
|
||||||
|
const isCurrent = index === currentStep;
|
||||||
|
const isClickable = isCompleted || index <= currentStep;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={step.id}
|
||||||
|
className={cn(
|
||||||
|
'relative flex-1',
|
||||||
|
index !== steps.length - 1 && (isCompactMode ? 'pr-4 sm:pr-8' : 'pr-8 sm:pr-20')
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Connector line */}
|
||||||
|
{index !== steps.length - 1 && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute top-4 right-0 h-0.5 bg-gray-200',
|
||||||
|
isCompactMode ? 'left-6' : 'left-8'
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="h-full bg-[#fe7400]"
|
||||||
|
initial={{ width: '0%' }}
|
||||||
|
animate={{ width: isCompleted ? '100%' : '0%' }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => isClickable && onStepClick?.(index)}
|
||||||
|
disabled={!isClickable}
|
||||||
|
className={cn(
|
||||||
|
'group flex flex-col items-center',
|
||||||
|
isClickable ? 'cursor-pointer' : 'cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
aria-current={isCurrent ? 'step' : undefined}
|
||||||
|
>
|
||||||
|
{/* Step circle */}
|
||||||
|
<motion.div
|
||||||
|
className={cn(
|
||||||
|
'relative z-10 flex items-center justify-center rounded-full border-2 transition-colors duration-200',
|
||||||
|
isCompactMode ? 'h-6 w-6' : 'h-8 w-8',
|
||||||
|
isCompleted
|
||||||
|
? 'bg-[#fe7400] border-[#fe7400]'
|
||||||
|
: isCurrent
|
||||||
|
? 'border-[#fe7400] bg-white'
|
||||||
|
: 'border-gray-300 bg-white'
|
||||||
|
)}
|
||||||
|
whileHover={isClickable ? { scale: 1.1 } : {}}
|
||||||
|
whileTap={isClickable ? { scale: 0.95 } : {}}
|
||||||
|
>
|
||||||
|
{isCompleted ? (
|
||||||
|
<Check className={cn(isCompactMode ? 'h-3 w-3' : 'h-4 w-4', 'text-white')} />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'font-semibold',
|
||||||
|
isCompactMode ? 'text-xs' : 'text-sm',
|
||||||
|
isCurrent ? 'text-[#fe7400]' : 'text-gray-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Step label - hidden on mobile for compact mode */}
|
||||||
|
<div className={cn('mt-2 text-center', isCompactMode && 'hidden sm:block')}>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'font-medium whitespace-nowrap',
|
||||||
|
isCompactMode ? 'text-[10px]' : 'text-xs',
|
||||||
|
isCurrent ? 'text-[#fe7400]' : isCompleted ? 'text-[#333d49]' : 'text-gray-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCompactMode ? step.title.split(' ')[0] : step.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{/* Mobile step indicator for compact mode */}
|
||||||
|
{isCompactMode && (
|
||||||
|
<div className="sm:hidden mt-4 text-center">
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Step {currentStep + 1} of {steps.length}:
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-[#333d49] ml-1">
|
||||||
|
{steps[currentStep]?.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { WizardContainer } from './WizardContainer';
|
||||||
|
export { WizardProgress, type WizardProgressProps } from './WizardProgress';
|
||||||
|
export { WizardNavigation, type WizardNavigationProps } from './WizardNavigation';
|
||||||
|
export * from './steps';
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Building2, Users, Briefcase, MessageSquare } from 'lucide-react';
|
||||||
|
import { Input } from '@/components/ui';
|
||||||
|
import { industries } from '@/lib/pricing-data';
|
||||||
|
import type { CompanyInfo, Industry } from '@/types/quote';
|
||||||
|
|
||||||
|
export interface Step1CompanyProfileProps {
|
||||||
|
companyInfo: CompanyInfo;
|
||||||
|
onUpdateCompany: (data: Partial<CompanyInfo>) => void;
|
||||||
|
onSetEndpointCount: (count: number) => void;
|
||||||
|
onSetIndustry: (industry: Industry | '') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step1CompanyProfile({
|
||||||
|
companyInfo,
|
||||||
|
onUpdateCompany,
|
||||||
|
onSetEndpointCount,
|
||||||
|
onSetIndustry,
|
||||||
|
}: Step1CompanyProfileProps) {
|
||||||
|
const handleEndpointChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = parseInt(e.target.value, 10);
|
||||||
|
if (!isNaN(value) && value >= 1) {
|
||||||
|
onSetEndpointCount(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIndustryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
onSetIndustry(e.target.value as Industry | '');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* Company Name (Optional) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
|
||||||
|
<Building2 className="w-4 h-4 text-[#fe7400]" />
|
||||||
|
Company Name
|
||||||
|
<span className="text-gray-400 font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={companyInfo.name}
|
||||||
|
onChange={(e) => onUpdateCompany({ name: e.target.value })}
|
||||||
|
placeholder="Enter your company name"
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Number of Endpoints (Required) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
|
||||||
|
<Users className="w-4 h-4 text-[#fe7400]" />
|
||||||
|
Number of Endpoints / Employees
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={companyInfo.endpointCount}
|
||||||
|
onChange={handleEndpointChange}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
devices requiring monitoring and support
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Include workstations, laptops, and servers that need IT support
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Industry Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
|
||||||
|
<Briefcase className="w-4 h-4 text-[#fe7400]" />
|
||||||
|
Industry
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={companyInfo.industry}
|
||||||
|
onChange={handleIndustryChange}
|
||||||
|
className="w-full max-w-md px-4 py-2.5 rounded-lg border border-gray-300 text-[#333d49] focus:outline-none focus:ring-2 focus:ring-[#fe7400]/20 focus:border-[#fe7400] transition-all duration-200"
|
||||||
|
>
|
||||||
|
<option value="">Select your industry...</option>
|
||||||
|
{industries.map((industry) => (
|
||||||
|
<option key={industry} value={industry}>
|
||||||
|
{industry}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
This helps us understand compliance requirements and best practices for your sector
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes (Optional) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
|
||||||
|
<MessageSquare className="w-4 h-4 text-[#fe7400]" />
|
||||||
|
What brings you here today?
|
||||||
|
<span className="text-gray-400 font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={companyInfo.notes}
|
||||||
|
onChange={(e) => onUpdateCompany({ notes: e.target.value })}
|
||||||
|
placeholder="Tell us about your current IT challenges or what you're looking for..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-2.5 rounded-lg border border-gray-300 text-[#333d49] placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/20 focus:border-[#fe7400] transition-all duration-200 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="bg-[#fe7400]/5 border border-[#fe7400]/20 rounded-lg p-4 mt-6"
|
||||||
|
>
|
||||||
|
<h4 className="font-medium text-[#333d49] mb-2">Why we ask this</h4>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Understanding your business size and industry helps us recommend the right
|
||||||
|
service tier and identify any compliance requirements (like HIPAA for healthcare
|
||||||
|
or PCI-DSS for retail) that may affect your IT needs.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Check, Server, HardDrive } from 'lucide-react';
|
||||||
|
import { Card, Button } from '@/components/ui';
|
||||||
|
import { ExpandableInfo } from '@/components/pricing/ExpandableInfo';
|
||||||
|
import { gpsTiers, equipmentMonitoring } from '@/lib/pricing-data';
|
||||||
|
import { formatCurrency } from '@/lib/utils';
|
||||||
|
import type { GPSSelection, GPSTierId } from '@/types/quote';
|
||||||
|
|
||||||
|
export interface Step2GPSMonitoringProps {
|
||||||
|
gpsSelection: GPSSelection;
|
||||||
|
onSetGPSTier: (tierId: GPSTierId) => void;
|
||||||
|
onSetEquipmentEnabled: (enabled: boolean) => void;
|
||||||
|
onSetEquipmentCount: (count: number) => void;
|
||||||
|
getGPSMonthly: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step2GPSMonitoring({
|
||||||
|
gpsSelection,
|
||||||
|
onSetGPSTier,
|
||||||
|
onSetEquipmentEnabled,
|
||||||
|
onSetEquipmentCount,
|
||||||
|
getGPSMonthly,
|
||||||
|
}: Step2GPSMonitoringProps) {
|
||||||
|
const calculateEquipmentPrice = () => {
|
||||||
|
if (!gpsSelection.includeEquipment || gpsSelection.equipmentDeviceCount === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const additionalDevices = Math.max(0, gpsSelection.equipmentDeviceCount - equipmentMonitoring.baseDevices);
|
||||||
|
return equipmentMonitoring.basePrice + (additionalDevices * equipmentMonitoring.additionalDevicePrice);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* Endpoint Count Display */}
|
||||||
|
<div className="flex items-center justify-between bg-gray-50 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Server className="w-5 h-5 text-[#fe7400]" />
|
||||||
|
<span className="font-medium text-[#333d49]">Endpoints to Monitor</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-[#fe7400]">
|
||||||
|
{gpsSelection.endpointCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tier Selection Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{gpsTiers.map((tier, index) => {
|
||||||
|
const isSelected = gpsSelection.tierId === tier.id;
|
||||||
|
const monthlyPrice = tier.pricePerEndpoint * gpsSelection.endpointCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={tier.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
whileHover={{ y: -4 }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
|
||||||
|
padding="none"
|
||||||
|
className={`relative overflow-hidden cursor-pointer ${
|
||||||
|
tier.recommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onSetGPSTier(tier.id)}
|
||||||
|
>
|
||||||
|
{/* Recommended Badge */}
|
||||||
|
{tier.recommended && (
|
||||||
|
<div className="absolute top-0 right-0">
|
||||||
|
<div className="bg-[#fe7400] text-white text-xs font-semibold px-3 py-1 rounded-bl-lg">
|
||||||
|
Recommended
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="text-lg font-semibold text-[#333d49]">{tier.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{tier.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-2xl font-bold text-[#333d49]">
|
||||||
|
{formatCurrency(monthlyPrice)}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 text-sm">/month</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{formatCurrency(tier.pricePerEndpoint)}/endpoint/month
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<ul className="space-y-2 mb-4">
|
||||||
|
{tier.features.slice(0, 4).map((feature, idx) => (
|
||||||
|
<li key={idx} className="flex items-start gap-2 text-sm">
|
||||||
|
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-gray-600">{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{tier.features.length > 4 && (
|
||||||
|
<li className="text-xs text-[#fe7400]">
|
||||||
|
+{tier.features.length - 4} more features
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Select Button */}
|
||||||
|
<Button
|
||||||
|
variant={isSelected ? 'primary' : 'outline'}
|
||||||
|
className="w-full"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isSelected ? 'Selected' : 'Select'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Equipment Monitoring Section */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="border border-gray-200 rounded-lg p-5"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<HardDrive className="w-5 h-5 text-[#fe7400]" />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-[#333d49]">Equipment Pack Monitoring</h4>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Monitor routers, switches, printers, and other network equipment
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={gpsSelection.includeEquipment}
|
||||||
|
onChange={(e) => onSetEquipmentEnabled(e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#fe7400]"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gpsSelection.includeEquipment && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="space-y-4 pt-4 border-t border-gray-100"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="text-sm text-gray-600">Number of devices:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={gpsSelection.equipmentDeviceCount}
|
||||||
|
onChange={(e) => onSetEquipmentCount(parseInt(e.target.value, 10) || 1)}
|
||||||
|
className="w-24 px-3 py-2 rounded-lg border border-gray-300 text-[#333d49] focus:outline-none focus:ring-2 focus:ring-[#fe7400]/20 focus:border-[#fe7400]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
<span className="font-medium">{formatCurrency(equipmentMonitoring.basePrice)}/month</span>
|
||||||
|
{' '}for up to {equipmentMonitoring.baseDevices} devices
|
||||||
|
{gpsSelection.equipmentDeviceCount > equipmentMonitoring.baseDevices && (
|
||||||
|
<span>
|
||||||
|
{' + '}
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatCurrency(equipmentMonitoring.additionalDevicePrice)}/device
|
||||||
|
</span>
|
||||||
|
{' for additional devices'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium text-[#fe7400] mt-1">
|
||||||
|
Equipment total: {formatCurrency(calculateEquipmentPrice())}/month
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Expandable Feature Info */}
|
||||||
|
<ExpandableInfo title="What's included in GPS Monitoring?">
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
||||||
|
<span><strong>Remote Monitoring:</strong> 24/7 monitoring of system health, performance, and security</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
||||||
|
<span><strong>Patch Management:</strong> Automated Windows and third-party application updates</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
||||||
|
<span><strong>Antivirus:</strong> Enterprise-grade protection with real-time threat detection</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
||||||
|
<span><strong>Help Desk:</strong> Access to our technical support team for issues and questions</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</ExpandableInfo>
|
||||||
|
|
||||||
|
{/* Monthly Total */}
|
||||||
|
<div className="bg-[#333d49] text-white rounded-lg p-5 flex items-center justify-between">
|
||||||
|
<span className="text-lg">GPS Monitoring Monthly Total</span>
|
||||||
|
<span className="text-3xl font-bold">
|
||||||
|
{formatCurrency(getGPSMonthly())}
|
||||||
|
<span className="text-lg font-normal opacity-75">/month</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Check, Clock, DollarSign, Zap } from 'lucide-react';
|
||||||
|
import { Card, Button } from '@/components/ui';
|
||||||
|
import { ExpandableInfo } from '@/components/pricing/ExpandableInfo';
|
||||||
|
import { supportPlans, blockTimeOptions } from '@/lib/pricing-data';
|
||||||
|
import { formatCurrency } from '@/lib/utils';
|
||||||
|
import type { SupportSelection, SupportPlanId, BlockTimeId } from '@/types/quote';
|
||||||
|
|
||||||
|
export interface Step3SupportPlanProps {
|
||||||
|
supportSelection: SupportSelection;
|
||||||
|
endpointCount: number;
|
||||||
|
onSetSupportPlan: (planId: SupportPlanId) => void;
|
||||||
|
onSetBlockTimeEnabled: (enabled: boolean) => void;
|
||||||
|
onSetBlockTime: (blockTimeId: BlockTimeId) => void;
|
||||||
|
getSupportMonthly: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step3SupportPlan({
|
||||||
|
supportSelection,
|
||||||
|
endpointCount,
|
||||||
|
onSetSupportPlan,
|
||||||
|
onSetBlockTimeEnabled,
|
||||||
|
onSetBlockTime,
|
||||||
|
getSupportMonthly,
|
||||||
|
}: Step3SupportPlanProps) {
|
||||||
|
// Recommend plan based on endpoint count
|
||||||
|
const getRecommendedPlan = (): SupportPlanId => {
|
||||||
|
if (endpointCount <= 10) return 'essential';
|
||||||
|
if (endpointCount <= 25) return 'standard';
|
||||||
|
if (endpointCount <= 50) return 'premium';
|
||||||
|
return 'priority';
|
||||||
|
};
|
||||||
|
|
||||||
|
const recommendedPlanId = getRecommendedPlan();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* Plan Selection Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{supportPlans.map((plan, index) => {
|
||||||
|
const isSelected = supportSelection.planId === plan.id;
|
||||||
|
const isRecommended = plan.id === recommendedPlanId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={plan.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
whileHover={{ y: -4 }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
variant={isSelected ? 'highlighted' : isRecommended ? 'elevated' : 'default'}
|
||||||
|
padding="none"
|
||||||
|
className={`relative overflow-hidden cursor-pointer h-full ${
|
||||||
|
isRecommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onSetSupportPlan(plan.id)}
|
||||||
|
>
|
||||||
|
{/* Recommended Badge */}
|
||||||
|
{isRecommended && (
|
||||||
|
<div className="absolute top-0 right-0">
|
||||||
|
<div className="bg-[#fe7400] text-white text-xs font-semibold px-2 py-1 rounded-bl-lg">
|
||||||
|
For You
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Header */}
|
||||||
|
<h3 className="text-lg font-semibold text-[#333d49] mb-1">{plan.name}</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">{plan.description}</p>
|
||||||
|
|
||||||
|
{/* Pricing */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-2xl font-bold text-[#333d49]">
|
||||||
|
{formatCurrency(plan.monthlyPrice)}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 text-xs">/mo</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hours Included */}
|
||||||
|
<div className="flex items-center gap-2 mb-3 p-2 bg-gray-50 rounded-lg">
|
||||||
|
<Clock className="w-4 h-4 text-[#fe7400]" />
|
||||||
|
<span className="text-sm font-medium text-[#333d49]">
|
||||||
|
{plan.includedHours} hrs included
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Effective Rate */}
|
||||||
|
<div className="flex items-center gap-2 mb-4 text-sm text-gray-600">
|
||||||
|
<DollarSign className="w-4 h-4" />
|
||||||
|
<span>
|
||||||
|
{formatCurrency(plan.effectiveHourlyRate)}/hr effective
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Select Button */}
|
||||||
|
<Button
|
||||||
|
variant={isSelected ? 'primary' : 'outline'}
|
||||||
|
className="w-full"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isSelected ? 'Selected' : 'Select'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Block Time Option */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="border border-gray-200 rounded-lg p-5"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Zap className="w-5 h-5 text-[#fe7400]" />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-[#333d49]">Add Block Time</h4>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Pre-purchase additional support hours at a discounted rate
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={supportSelection.useBlockTime}
|
||||||
|
onChange={(e) => onSetBlockTimeEnabled(e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#fe7400]"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{supportSelection.useBlockTime && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="space-y-3 pt-4 border-t border-gray-100"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
{blockTimeOptions.map((option) => {
|
||||||
|
const isSelected = supportSelection.blockTimeId === option.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={option.id}
|
||||||
|
onClick={() => onSetBlockTime(option.id)}
|
||||||
|
className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-[#fe7400] bg-[#fe7400]/5'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-lg font-bold text-[#333d49]">
|
||||||
|
{option.hours} Hours
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-[#fe7400]">
|
||||||
|
{formatCurrency(option.price)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{formatCurrency(option.effectiveHourlyRate)}/hr
|
||||||
|
</div>
|
||||||
|
{option.hours === 30 && (
|
||||||
|
<div className="mt-2 text-xs font-medium text-green-600">
|
||||||
|
Best Value
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Expandable Info */}
|
||||||
|
<ExpandableInfo title="How does support work?">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p>
|
||||||
|
Your monthly support plan includes a set number of hours for help desk assistance,
|
||||||
|
remote troubleshooting, and project work. Hours roll over for 30 days if unused.
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
||||||
|
<span><strong>Help Desk:</strong> Phone, email, and chat support for daily IT questions</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
||||||
|
<span><strong>Remote Support:</strong> Screen sharing and remote control for quick fixes</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
||||||
|
<span><strong>On-Site Support:</strong> Available for Premium and Priority plans</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Block time is great for planned projects, office moves, or seasonal busy periods.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ExpandableInfo>
|
||||||
|
|
||||||
|
{/* Monthly Total */}
|
||||||
|
<div className="bg-[#333d49] text-white rounded-lg p-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-lg">Support Monthly Total</span>
|
||||||
|
{supportSelection.useBlockTime && supportSelection.blockTimeId && (
|
||||||
|
<p className="text-sm opacity-75">
|
||||||
|
Includes{' '}
|
||||||
|
{blockTimeOptions.find((b) => b.id === supportSelection.blockTimeId)?.hours} hr block
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-3xl font-bold">
|
||||||
|
{formatCurrency(getSupportMonthly())}
|
||||||
|
<span className="text-lg font-normal opacity-75">/month</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Check, Phone, Headphones, Plus, Minus, X } from 'lucide-react';
|
||||||
|
import { Card, Button, Input } from '@/components/ui';
|
||||||
|
import { ExpandableInfo } from '@/components/pricing/ExpandableInfo';
|
||||||
|
import { voipTiers, voipHardware } from '@/lib/pricing-data';
|
||||||
|
import { formatCurrency } from '@/lib/utils';
|
||||||
|
import type { VoIPSelection, VoIPTierId, HardwareSelection } from '@/types/quote';
|
||||||
|
|
||||||
|
export interface Step4VoIPProps {
|
||||||
|
voipSelection: VoIPSelection;
|
||||||
|
onSetVoIPEnabled: (enabled: boolean) => void;
|
||||||
|
onSetVoIPTier: (tierId: VoIPTierId) => void;
|
||||||
|
onSetVoIPUserCount: (count: number) => void;
|
||||||
|
onAddHardware: (hardwareId: string, quantity: number, isRental: boolean) => void;
|
||||||
|
onRemoveHardware: (hardwareId: string) => void;
|
||||||
|
onUpdateHardwareQuantity: (hardwareId: string, quantity: number) => void;
|
||||||
|
getVoIPMonthly: () => number;
|
||||||
|
getVoIPOneTime: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step4VoIP({
|
||||||
|
voipSelection,
|
||||||
|
onSetVoIPEnabled,
|
||||||
|
onSetVoIPTier,
|
||||||
|
onSetVoIPUserCount,
|
||||||
|
onAddHardware,
|
||||||
|
onRemoveHardware,
|
||||||
|
onUpdateHardwareQuantity,
|
||||||
|
getVoIPMonthly,
|
||||||
|
getVoIPOneTime,
|
||||||
|
}: Step4VoIPProps) {
|
||||||
|
const [showHardware, setShowHardware] = useState(false);
|
||||||
|
|
||||||
|
const getHardwareSelection = (hardwareId: string): HardwareSelection | undefined => {
|
||||||
|
return voipSelection.hardware.find((h) => h.hardwareId === hardwareId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHardwareToggle = (hardwareId: string, isRental: boolean) => {
|
||||||
|
const existing = getHardwareSelection(hardwareId);
|
||||||
|
if (existing) {
|
||||||
|
onRemoveHardware(hardwareId);
|
||||||
|
} else {
|
||||||
|
onAddHardware(hardwareId, 1, isRental);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuantityChange = (hardwareId: string, delta: number) => {
|
||||||
|
const existing = getHardwareSelection(hardwareId);
|
||||||
|
if (existing) {
|
||||||
|
const newQuantity = Math.max(1, existing.quantity + delta);
|
||||||
|
onUpdateHardwareQuantity(hardwareId, newQuantity);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* VoIP Toggle */}
|
||||||
|
<div className="bg-gray-50 rounded-lg p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Phone className="w-6 h-6 text-[#fe7400]" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-[#333d49]">Do you need business phones?</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Modern VoIP phone system with advanced features
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={voipSelection.enabled}
|
||||||
|
onChange={(e) => onSetVoIPEnabled(e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-[#fe7400]"></div>
|
||||||
|
<span className="ml-3 text-sm font-medium text-gray-700">
|
||||||
|
{voipSelection.enabled ? 'Yes' : 'No'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{voipSelection.enabled && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* User Count */}
|
||||||
|
<div className="flex items-center gap-4 p-4 border border-gray-200 rounded-lg">
|
||||||
|
<label className="text-sm font-medium text-[#333d49]">Number of phone users:</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={voipSelection.userCount}
|
||||||
|
onChange={(e) => onSetVoIPUserCount(parseInt(e.target.value, 10) || 1)}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tier Selection */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{voipTiers.map((tier, index) => {
|
||||||
|
const isSelected = voipSelection.tierId === tier.id;
|
||||||
|
const monthlyPrice = tier.pricePerUser * voipSelection.userCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={tier.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
whileHover={{ y: -4 }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
|
||||||
|
padding="none"
|
||||||
|
className={`relative overflow-hidden cursor-pointer h-full ${
|
||||||
|
tier.recommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onSetVoIPTier(tier.id)}
|
||||||
|
>
|
||||||
|
{tier.recommended && (
|
||||||
|
<div className="absolute top-0 right-0">
|
||||||
|
<div className="bg-[#fe7400] text-white text-xs font-semibold px-2 py-1 rounded-bl-lg">
|
||||||
|
Popular
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-[#333d49]">{tier.name}</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">{tier.description}</p>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<span className="text-xl font-bold text-[#333d49]">
|
||||||
|
{formatCurrency(monthlyPrice)}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 text-xs">/mo</span>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{formatCurrency(tier.pricePerUser)}/user
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-1 mb-4">
|
||||||
|
{tier.features.slice(0, 3).map((feature, idx) => (
|
||||||
|
<li key={idx} className="flex items-start gap-1.5 text-xs">
|
||||||
|
<Check className="w-3 h-3 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-gray-600">{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={isSelected ? 'primary' : 'outline'}
|
||||||
|
className="w-full"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isSelected ? 'Selected' : 'Select'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hardware Section */}
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowHardware(!showHardware)}
|
||||||
|
className="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Headphones className="w-5 h-5 text-[#fe7400]" />
|
||||||
|
<span className="font-medium text-[#333d49]">
|
||||||
|
Phone Hardware (Optional)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{showHardware ? 'Hide' : 'Show'} options
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showHardware && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="p-4 space-y-3"
|
||||||
|
>
|
||||||
|
{voipHardware.map((hardware) => {
|
||||||
|
const selection = getHardwareSelection(hardware.id);
|
||||||
|
const isSelected = !!selection;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={hardware.id}
|
||||||
|
className={`p-4 rounded-lg border-2 transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-[#fe7400] bg-[#fe7400]/5'
|
||||||
|
: 'border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-medium text-[#333d49]">{hardware.name}</h4>
|
||||||
|
<p className="text-sm text-gray-500">{hardware.description}</p>
|
||||||
|
<div className="flex gap-4 mt-2 text-sm">
|
||||||
|
<span className="text-[#333d49]">
|
||||||
|
Buy: <strong>{formatCurrency(hardware.oneTimePrice)}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="text-[#333d49]">
|
||||||
|
Rent: <strong>{formatCurrency(hardware.monthlyRental)}</strong>/mo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSelected ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Rental Toggle */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAddHardware(hardware.id, selection.quantity, false)}
|
||||||
|
className={`px-2 py-1 text-xs rounded ${
|
||||||
|
!selection.isRental
|
||||||
|
? 'bg-[#fe7400] text-white'
|
||||||
|
: 'bg-gray-200 text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Buy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAddHardware(hardware.id, selection.quantity, true)}
|
||||||
|
className={`px-2 py-1 text-xs rounded ${
|
||||||
|
selection.isRental
|
||||||
|
? 'bg-[#fe7400] text-white'
|
||||||
|
: 'bg-gray-200 text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Rent
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quantity */}
|
||||||
|
<div className="flex items-center gap-2 border border-gray-300 rounded-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleQuantityChange(hardware.id, -1)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-l-lg"
|
||||||
|
disabled={selection.quantity <= 1}
|
||||||
|
>
|
||||||
|
<Minus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<span className="w-8 text-center font-medium">
|
||||||
|
{selection.quantity}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleQuantityChange(hardware.id, 1)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-r-lg"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remove */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemoveHardware(hardware.id)}
|
||||||
|
className="p-2 text-red-500 hover:bg-red-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleHardwareToggle(hardware.id, false)}
|
||||||
|
>
|
||||||
|
Add (Buy)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleHardwareToggle(hardware.id, true)}
|
||||||
|
>
|
||||||
|
Add (Rent)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<ExpandableInfo title="VoIP Features & Benefits">
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
||||||
|
<span>Unlimited local and long-distance calling</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
||||||
|
<span>Mobile apps for iOS and Android - take calls anywhere</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
||||||
|
<span>Auto-attendant and professional voicemail</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Check className="w-4 h-4 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
||||||
|
<span>Keep your existing phone numbers</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</ExpandableInfo>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-[#333d49] text-white rounded-lg p-5 flex items-center justify-between">
|
||||||
|
<span className="text-lg">VoIP Monthly Total</span>
|
||||||
|
<span className="text-3xl font-bold">
|
||||||
|
{formatCurrency(getVoIPMonthly())}
|
||||||
|
<span className="text-lg font-normal opacity-75">/month</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{getVoIPOneTime() > 0 && (
|
||||||
|
<div className="bg-gray-100 rounded-lg p-4 flex items-center justify-between">
|
||||||
|
<span className="text-gray-700">Hardware Purchase (One-Time)</span>
|
||||||
|
<span className="text-xl font-bold text-[#333d49]">
|
||||||
|
{formatCurrency(getVoIPOneTime())}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{!voipSelection.enabled && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="text-center py-8 text-gray-500"
|
||||||
|
>
|
||||||
|
<Phone className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>You can always add VoIP services later.</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Check, Globe, Mail, Cloud, Server } from 'lucide-react';
|
||||||
|
import { Card, Button, Input } from '@/components/ui';
|
||||||
|
import { ExpandableInfo } from '@/components/pricing/ExpandableInfo';
|
||||||
|
import { webHostingTiers, emailTiers } from '@/lib/pricing-data';
|
||||||
|
import { formatCurrency } from '@/lib/utils';
|
||||||
|
import type {
|
||||||
|
WebHostingSelection,
|
||||||
|
WebHostingTierId,
|
||||||
|
EmailSelection,
|
||||||
|
EmailTierId,
|
||||||
|
EmailProvider,
|
||||||
|
} from '@/types/quote';
|
||||||
|
|
||||||
|
export interface Step5WebEmailProps {
|
||||||
|
webHostingSelection: WebHostingSelection;
|
||||||
|
emailSelection: EmailSelection;
|
||||||
|
onSetWebHostingEnabled: (enabled: boolean) => void;
|
||||||
|
onSetWebHostingTier: (tierId: WebHostingTierId) => void;
|
||||||
|
onSetEmailEnabled: (enabled: boolean) => void;
|
||||||
|
onSetEmailProvider: (provider: EmailProvider) => void;
|
||||||
|
onSetEmailTier: (tierId: EmailTierId) => void;
|
||||||
|
onSetMailboxCount: (count: number) => void;
|
||||||
|
getWebHostingMonthly: () => number;
|
||||||
|
getEmailMonthly: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step5WebEmail({
|
||||||
|
webHostingSelection,
|
||||||
|
emailSelection,
|
||||||
|
onSetWebHostingEnabled,
|
||||||
|
onSetWebHostingTier,
|
||||||
|
onSetEmailEnabled,
|
||||||
|
onSetEmailProvider,
|
||||||
|
onSetEmailTier,
|
||||||
|
onSetMailboxCount,
|
||||||
|
getWebHostingMonthly,
|
||||||
|
getEmailMonthly,
|
||||||
|
}: Step5WebEmailProps) {
|
||||||
|
const whmTiers = emailTiers.filter((t) => t.provider === 'whm');
|
||||||
|
const m365Tiers = emailTiers.filter((t) => t.provider === 'm365');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="space-y-8"
|
||||||
|
>
|
||||||
|
{/* Web Hosting Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-gray-50 rounded-lg p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Globe className="w-6 h-6 text-[#fe7400]" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-[#333d49]">Web Hosting</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Managed WordPress hosting with SSL and backups
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={webHostingSelection.enabled}
|
||||||
|
onChange={(e) => onSetWebHostingEnabled(e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-[#fe7400]"></div>
|
||||||
|
<span className="ml-3 text-sm font-medium text-gray-700">
|
||||||
|
{webHostingSelection.enabled ? 'Yes' : 'No'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{webHostingSelection.enabled && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{webHostingTiers.map((tier, index) => {
|
||||||
|
const isSelected = webHostingSelection.tierId === tier.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={tier.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
whileHover={{ y: -4 }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
|
||||||
|
padding="none"
|
||||||
|
className={`relative overflow-hidden cursor-pointer h-full ${
|
||||||
|
tier.recommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onSetWebHostingTier(tier.id)}
|
||||||
|
>
|
||||||
|
{tier.recommended && (
|
||||||
|
<div className="absolute top-0 right-0">
|
||||||
|
<div className="bg-[#fe7400] text-white text-xs font-semibold px-2 py-1 rounded-bl-lg">
|
||||||
|
Popular
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="text-lg font-semibold text-[#333d49]">{tier.name}</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">{tier.description}</p>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<span className="text-2xl font-bold text-[#333d49]">
|
||||||
|
{formatCurrency(tier.monthlyPrice)}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 text-sm">/mo</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mb-3 text-xs text-gray-600">
|
||||||
|
<span>{tier.storage}</span>
|
||||||
|
<span>|</span>
|
||||||
|
<span>{tier.sites === -1 ? 'Unlimited' : tier.sites} site{tier.sites !== 1 && 's'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-1 mb-4">
|
||||||
|
{tier.features.slice(0, 3).map((feature, idx) => (
|
||||||
|
<li key={idx} className="flex items-start gap-1.5 text-xs">
|
||||||
|
<Check className="w-3 h-3 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-gray-600">{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={isSelected ? 'primary' : 'outline'}
|
||||||
|
className="w-full"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isSelected ? 'Selected' : 'Select'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-gray-200" />
|
||||||
|
|
||||||
|
{/* Email Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-gray-50 rounded-lg p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Mail className="w-6 h-6 text-[#fe7400]" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-[#333d49]">Email Service</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Professional business email hosting
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={emailSelection.enabled}
|
||||||
|
onChange={(e) => onSetEmailEnabled(e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#fe7400]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-[#fe7400]"></div>
|
||||||
|
<span className="ml-3 text-sm font-medium text-gray-700">
|
||||||
|
{emailSelection.enabled ? 'Yes' : 'No'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{emailSelection.enabled && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
{/* Mailbox Count */}
|
||||||
|
<div className="flex items-center gap-4 p-4 border border-gray-200 rounded-lg">
|
||||||
|
<label className="text-sm font-medium text-[#333d49]">
|
||||||
|
Number of mailboxes:
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={emailSelection.mailboxCount}
|
||||||
|
onChange={(e) => onSetMailboxCount(parseInt(e.target.value, 10) || 1)}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Provider Selection */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => onSetEmailProvider('whm')}
|
||||||
|
className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
||||||
|
emailSelection.provider === 'whm'
|
||||||
|
? 'border-[#fe7400] bg-[#fe7400]/5'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<Server className="w-5 h-5 text-[#fe7400]" />
|
||||||
|
<h4 className="font-semibold text-[#333d49]">Self-Hosted (WHM)</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Budget-friendly email hosting on our servers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={() => onSetEmailProvider('m365')}
|
||||||
|
className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
||||||
|
emailSelection.provider === 'm365'
|
||||||
|
? 'border-[#fe7400] bg-[#fe7400]/5'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<Cloud className="w-5 h-5 text-[#fe7400]" />
|
||||||
|
<h4 className="font-semibold text-[#333d49]">Microsoft 365</h4>
|
||||||
|
<span className="text-xs bg-[#fe7400] text-white px-2 py-0.5 rounded">
|
||||||
|
Recommended
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Full Microsoft suite with Teams, OneDrive, and Office apps
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tier Selection based on Provider */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{(emailSelection.provider === 'whm' ? whmTiers : m365Tiers).map((tier, index) => {
|
||||||
|
const isSelected = emailSelection.tierId === tier.id;
|
||||||
|
const monthlyPrice = tier.pricePerMailbox * emailSelection.mailboxCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={tier.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
whileHover={{ y: -4 }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
variant={isSelected ? 'highlighted' : tier.recommended ? 'elevated' : 'default'}
|
||||||
|
padding="none"
|
||||||
|
className={`relative overflow-hidden cursor-pointer h-full ${
|
||||||
|
tier.recommended && !isSelected ? 'ring-2 ring-[#333d49]' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onSetEmailTier(tier.id)}
|
||||||
|
>
|
||||||
|
{tier.recommended && (
|
||||||
|
<div className="absolute top-0 right-0">
|
||||||
|
<div className="bg-[#fe7400] text-white text-xs font-semibold px-2 py-1 rounded-bl-lg">
|
||||||
|
Popular
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="text-base font-semibold text-[#333d49]">{tier.name}</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-2">{tier.storage}</p>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<span className="text-xl font-bold text-[#333d49]">
|
||||||
|
{formatCurrency(monthlyPrice)}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 text-xs">/mo</span>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{formatCurrency(tier.pricePerMailbox)}/mailbox
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-1 mb-3">
|
||||||
|
{tier.features.slice(0, 3).map((feature, idx) => (
|
||||||
|
<li key={idx} className="flex items-start gap-1.5 text-xs">
|
||||||
|
<Check className="w-3 h-3 text-[#fe7400] mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-gray-600">{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={isSelected ? 'primary' : 'outline'}
|
||||||
|
className="w-full"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isSelected ? 'Selected' : 'Select'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<ExpandableInfo title="WHM vs Microsoft 365 - Which should I choose?">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h5 className="font-medium text-[#333d49]">Self-Hosted (WHM)</h5>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Best for budget-conscious businesses that just need reliable email.
|
||||||
|
Includes webmail access and standard email features.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="font-medium text-[#333d49]">Microsoft 365</h5>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Best for businesses that need collaboration tools. Includes Outlook,
|
||||||
|
Teams for video calls, OneDrive cloud storage, and the full Office
|
||||||
|
suite (Word, Excel, PowerPoint).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ExpandableInfo>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{webHostingSelection.enabled && (
|
||||||
|
<div className="bg-gray-100 rounded-lg p-4 flex items-center justify-between">
|
||||||
|
<span className="text-gray-700">Web Hosting</span>
|
||||||
|
<span className="text-xl font-bold text-[#333d49]">
|
||||||
|
{formatCurrency(getWebHostingMonthly())}
|
||||||
|
<span className="text-sm font-normal">/mo</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{emailSelection.enabled && (
|
||||||
|
<div className="bg-gray-100 rounded-lg p-4 flex items-center justify-between">
|
||||||
|
<span className="text-gray-700">Email Service</span>
|
||||||
|
<span className="text-xl font-bold text-[#333d49]">
|
||||||
|
{formatCurrency(getEmailMonthly())}
|
||||||
|
<span className="text-sm font-normal">/mo</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(webHostingSelection.enabled || emailSelection.enabled) && (
|
||||||
|
<div className="bg-[#333d49] text-white rounded-lg p-5 flex items-center justify-between">
|
||||||
|
<span className="text-lg">Web & Email Total</span>
|
||||||
|
<span className="text-3xl font-bold">
|
||||||
|
{formatCurrency(getWebHostingMonthly() + getEmailMonthly())}
|
||||||
|
<span className="text-lg font-normal opacity-75">/month</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Edit2, Monitor, Headphones, Phone, Globe, Mail, Printer, DollarSign } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui';
|
||||||
|
import {
|
||||||
|
gpsTiers,
|
||||||
|
supportPlans,
|
||||||
|
blockTimeOptions,
|
||||||
|
voipTiers,
|
||||||
|
webHostingTiers,
|
||||||
|
emailTiers,
|
||||||
|
} from '@/lib/pricing-data';
|
||||||
|
import { formatCurrency } from '@/lib/utils';
|
||||||
|
import type { QuoteData, QuoteResult } from '@/types/quote';
|
||||||
|
|
||||||
|
export interface Step6SummaryProps {
|
||||||
|
quoteData: QuoteData;
|
||||||
|
quoteResult: QuoteResult | null;
|
||||||
|
onGoToStep: (step: number) => void;
|
||||||
|
onCalculateQuote: () => QuoteResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step6Summary({
|
||||||
|
quoteData,
|
||||||
|
quoteResult,
|
||||||
|
onGoToStep,
|
||||||
|
onCalculateQuote,
|
||||||
|
}: Step6SummaryProps) {
|
||||||
|
// Calculate fresh quote if not available
|
||||||
|
const result = quoteResult || onCalculateQuote();
|
||||||
|
|
||||||
|
const gpsTier = gpsTiers.find((t) => t.id === quoteData.gps.tierId);
|
||||||
|
const supportPlan = supportPlans.find((p) => p.id === quoteData.support.planId);
|
||||||
|
const blockTime = quoteData.support.useBlockTime && quoteData.support.blockTimeId
|
||||||
|
? blockTimeOptions.find((b) => b.id === quoteData.support.blockTimeId)
|
||||||
|
: null;
|
||||||
|
const voipTier = voipTiers.find((t) => t.id === quoteData.voip.tierId);
|
||||||
|
const webTier = webHostingTiers.find((t) => t.id === quoteData.webHosting.tierId);
|
||||||
|
const emailTier = emailTiers.find((t) => t.id === quoteData.email.tierId);
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-[#333d49] mb-2">Your Quote Summary</h2>
|
||||||
|
<p className="text-gray-500">Review your selections before submitting</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Company Info */}
|
||||||
|
{quoteData.company.name && (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-sm text-gray-500">Quote prepared for:</p>
|
||||||
|
<p className="font-semibold text-[#333d49] text-lg">{quoteData.company.name}</p>
|
||||||
|
{quoteData.company.industry && (
|
||||||
|
<p className="text-sm text-gray-600">{quoteData.company.industry}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* GPS Monitoring Section */}
|
||||||
|
<SummarySection
|
||||||
|
icon={<Monitor className="w-5 h-5" />}
|
||||||
|
title="GPS Monitoring"
|
||||||
|
monthlyTotal={result.gpsMonthly}
|
||||||
|
onEdit={() => onGoToStep(1)}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SummaryLine
|
||||||
|
label={`${gpsTier?.name} Plan (${quoteData.gps.endpointCount} endpoints)`}
|
||||||
|
value={formatCurrency(result.breakdown.gps.monitoring)}
|
||||||
|
/>
|
||||||
|
{quoteData.gps.includeEquipment && quoteData.gps.equipmentDeviceCount > 0 && (
|
||||||
|
<SummaryLine
|
||||||
|
label={`Equipment Pack (${quoteData.gps.equipmentDeviceCount} devices)`}
|
||||||
|
value={formatCurrency(result.breakdown.gps.equipment)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SummarySection>
|
||||||
|
|
||||||
|
{/* Support Plan Section */}
|
||||||
|
<SummarySection
|
||||||
|
icon={<Headphones className="w-5 h-5" />}
|
||||||
|
title="Support Plan"
|
||||||
|
monthlyTotal={result.supportMonthly}
|
||||||
|
onEdit={() => onGoToStep(2)}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SummaryLine
|
||||||
|
label={`${supportPlan?.name} Plan (${supportPlan?.includedHours} hrs/mo)`}
|
||||||
|
value={formatCurrency(result.breakdown.support.plan)}
|
||||||
|
/>
|
||||||
|
{blockTime && (
|
||||||
|
<SummaryLine
|
||||||
|
label={`Block Time (${blockTime.hours} hours)`}
|
||||||
|
value={formatCurrency(result.breakdown.support.blockTime)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SummarySection>
|
||||||
|
|
||||||
|
{/* VoIP Section */}
|
||||||
|
{quoteData.voip.enabled && (
|
||||||
|
<SummarySection
|
||||||
|
icon={<Phone className="w-5 h-5" />}
|
||||||
|
title="VoIP Phone System"
|
||||||
|
monthlyTotal={result.voipMonthly}
|
||||||
|
onEdit={() => onGoToStep(3)}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SummaryLine
|
||||||
|
label={`${voipTier?.name} Plan (${quoteData.voip.userCount} users)`}
|
||||||
|
value={formatCurrency(result.breakdown.voip.service)}
|
||||||
|
/>
|
||||||
|
{result.breakdown.voip.hardware > 0 && (
|
||||||
|
<SummaryLine
|
||||||
|
label="Hardware Rental"
|
||||||
|
value={formatCurrency(result.breakdown.voip.hardware)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SummarySection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Web Hosting Section */}
|
||||||
|
{quoteData.webHosting.enabled && (
|
||||||
|
<SummarySection
|
||||||
|
icon={<Globe className="w-5 h-5" />}
|
||||||
|
title="Web Hosting"
|
||||||
|
monthlyTotal={result.webHostingMonthly}
|
||||||
|
onEdit={() => onGoToStep(4)}
|
||||||
|
>
|
||||||
|
<SummaryLine
|
||||||
|
label={`${webTier?.name} Plan (${webTier?.storage}, ${webTier?.sites === -1 ? 'unlimited' : webTier?.sites} sites)`}
|
||||||
|
value={formatCurrency(result.webHostingMonthly)}
|
||||||
|
/>
|
||||||
|
</SummarySection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email Section */}
|
||||||
|
{quoteData.email.enabled && (
|
||||||
|
<SummarySection
|
||||||
|
icon={<Mail className="w-5 h-5" />}
|
||||||
|
title="Email Service"
|
||||||
|
monthlyTotal={result.emailMonthly}
|
||||||
|
onEdit={() => onGoToStep(4)}
|
||||||
|
>
|
||||||
|
<SummaryLine
|
||||||
|
label={`${emailTier?.name} (${quoteData.email.mailboxCount} mailboxes)`}
|
||||||
|
value={formatCurrency(result.emailMonthly)}
|
||||||
|
/>
|
||||||
|
</SummarySection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="bg-[#333d49] text-white rounded-xl p-6 mt-8"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-lg">Monthly Total</span>
|
||||||
|
<span className="text-4xl font-bold">
|
||||||
|
{formatCurrency(result.monthlyTotal)}
|
||||||
|
<span className="text-lg font-normal opacity-75">/mo</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.oneTimeTotal > 0 && (
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-white/20">
|
||||||
|
<span className="opacity-75">One-Time Costs (Hardware)</span>
|
||||||
|
<span className="text-xl font-semibold">
|
||||||
|
{formatCurrency(result.oneTimeTotal)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 pt-4 border-t border-white/20">
|
||||||
|
<div className="flex items-center justify-between text-sm opacity-75">
|
||||||
|
<span>Annual Investment</span>
|
||||||
|
<span>{formatCurrency(result.monthlyTotal * 12)}/year</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Breakdown Card */}
|
||||||
|
<div className="bg-gray-50 rounded-lg p-5">
|
||||||
|
<h4 className="font-semibold text-[#333d49] mb-4 flex items-center gap-2">
|
||||||
|
<DollarSign className="w-5 h-5 text-[#fe7400]" />
|
||||||
|
Monthly Breakdown
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<BreakdownRow label="GPS Monitoring" value={result.gpsMonthly} />
|
||||||
|
<BreakdownRow label="Support Plan" value={result.supportMonthly} />
|
||||||
|
{quoteData.voip.enabled && (
|
||||||
|
<BreakdownRow label="VoIP Phone System" value={result.voipMonthly} />
|
||||||
|
)}
|
||||||
|
{quoteData.webHosting.enabled && (
|
||||||
|
<BreakdownRow label="Web Hosting" value={result.webHostingMonthly} />
|
||||||
|
)}
|
||||||
|
{quoteData.email.enabled && (
|
||||||
|
<BreakdownRow label="Email Service" value={result.emailMonthly} />
|
||||||
|
)}
|
||||||
|
<div className="pt-3 border-t border-gray-200 flex justify-between font-bold text-lg">
|
||||||
|
<span className="text-[#333d49]">Total</span>
|
||||||
|
<span className="text-[#fe7400]">{formatCurrency(result.monthlyTotal)}/mo</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Print Button */}
|
||||||
|
<div className="flex justify-center pt-4 print:hidden">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handlePrint}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Printer className="w-4 h-4" />
|
||||||
|
Print Quote
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes Section */}
|
||||||
|
<div className="text-center text-sm text-gray-500 pt-4">
|
||||||
|
<p>This is an estimate. Final pricing may vary based on specific requirements.</p>
|
||||||
|
<p>Prices are subject to change. Quote valid for 30 days.</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper Components
|
||||||
|
|
||||||
|
interface SummarySectionProps {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
monthlyTotal: number;
|
||||||
|
onEdit: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummarySection({ icon, title, monthlyTotal, onEdit, children }: SummarySectionProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
className="border border-gray-200 rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="bg-gray-50 px-4 py-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-[#fe7400]">{icon}</span>
|
||||||
|
<span className="font-semibold text-[#333d49]">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="font-bold text-[#333d49]">
|
||||||
|
{formatCurrency(monthlyTotal)}/mo
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
className="flex items-center gap-1 text-sm text-[#fe7400] hover:text-[#e56800] transition-colors print:hidden"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">{children}</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SummaryLineProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryLine({ label, value }: SummaryLineProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600">{label}</span>
|
||||||
|
<span className="font-medium text-[#333d49]">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BreakdownRowProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreakdownRow({ label, value }: BreakdownRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">{label}</span>
|
||||||
|
<span className="font-medium text-[#333d49]">{formatCurrency(value)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { User, Mail, Phone, Building2, MessageSquare, CheckCircle } from 'lucide-react';
|
||||||
|
import { Input, Button } from '@/components/ui';
|
||||||
|
import { contactPreferences } from '@/lib/pricing-data';
|
||||||
|
import type { ContactInfo, ContactPreference, QuoteResult } from '@/types/quote';
|
||||||
|
import { formatCurrency } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface Step7ContactProps {
|
||||||
|
contactInfo: ContactInfo;
|
||||||
|
companyNameFromStep1: string;
|
||||||
|
quoteResult: QuoteResult | null;
|
||||||
|
onUpdateContact: (data: Partial<ContactInfo>) => void;
|
||||||
|
onSetContactPreference: (preference: ContactPreference) => void;
|
||||||
|
onSetAgreedToTerms: (agreed: boolean) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormErrors {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
agreedToTerms?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step7Contact({
|
||||||
|
contactInfo,
|
||||||
|
companyNameFromStep1,
|
||||||
|
quoteResult,
|
||||||
|
onUpdateContact,
|
||||||
|
onSetContactPreference,
|
||||||
|
onSetAgreedToTerms,
|
||||||
|
onSubmit,
|
||||||
|
isSubmitting,
|
||||||
|
}: Step7ContactProps) {
|
||||||
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
|
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// Pre-fill company name if available
|
||||||
|
if (companyNameFromStep1 && !contactInfo.companyName) {
|
||||||
|
onUpdateContact({ companyName: companyNameFromStep1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateEmail = (email: string): boolean => {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const newErrors: FormErrors = {};
|
||||||
|
|
||||||
|
if (!contactInfo.name.trim()) {
|
||||||
|
newErrors.name = 'Name is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contactInfo.email.trim()) {
|
||||||
|
newErrors.email = 'Email is required';
|
||||||
|
} else if (!validateEmail(contactInfo.email)) {
|
||||||
|
newErrors.email = 'Please enter a valid email address';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contactInfo.agreedToTerms) {
|
||||||
|
newErrors.agreedToTerms = 'You must agree to the terms';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = (field: string) => {
|
||||||
|
setTouched((prev) => ({ ...prev, [field]: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (validateForm()) {
|
||||||
|
onSubmit();
|
||||||
|
} else {
|
||||||
|
// Mark all fields as touched to show errors
|
||||||
|
setTouched({
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
agreedToTerms: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="max-w-2xl mx-auto"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-[#333d49] mb-2">Get Your Quote</h2>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
We will send your customized quote and contact you to discuss next steps.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quote Preview */}
|
||||||
|
{quoteResult && (
|
||||||
|
<div className="bg-[#fe7400]/10 border border-[#fe7400]/30 rounded-lg p-4 mb-6 flex items-center justify-between">
|
||||||
|
<span className="text-[#333d49] font-medium">Your Estimated Monthly Total:</span>
|
||||||
|
<span className="text-2xl font-bold text-[#fe7400]">
|
||||||
|
{formatCurrency(quoteResult.monthlyTotal)}/mo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{/* Contact Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
|
||||||
|
<User className="w-4 h-4 text-[#fe7400]" />
|
||||||
|
Contact Name
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={contactInfo.name}
|
||||||
|
onChange={(e) => onUpdateContact({ name: e.target.value })}
|
||||||
|
onBlur={() => handleBlur('name')}
|
||||||
|
placeholder="Your full name"
|
||||||
|
error={touched.name ? errors.name : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
|
||||||
|
<Mail className="w-4 h-4 text-[#fe7400]" />
|
||||||
|
Email Address
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={contactInfo.email}
|
||||||
|
onChange={(e) => onUpdateContact({ email: e.target.value })}
|
||||||
|
onBlur={() => handleBlur('email')}
|
||||||
|
placeholder="you@company.com"
|
||||||
|
error={touched.email ? errors.email : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
|
||||||
|
<Phone className="w-4 h-4 text-[#fe7400]" />
|
||||||
|
Phone Number
|
||||||
|
<span className="text-gray-400 font-normal">(recommended)</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="tel"
|
||||||
|
value={contactInfo.phone}
|
||||||
|
onChange={(e) => onUpdateContact({ phone: e.target.value })}
|
||||||
|
placeholder="(555) 123-4567"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Company Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
|
||||||
|
<Building2 className="w-4 h-4 text-[#fe7400]" />
|
||||||
|
Company Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={contactInfo.companyName}
|
||||||
|
onChange={(e) => onUpdateContact({ companyName: e.target.value })}
|
||||||
|
placeholder="Your company name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current IT Situation */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-[#333d49]">
|
||||||
|
<MessageSquare className="w-4 h-4 text-[#fe7400]" />
|
||||||
|
Current IT Situation
|
||||||
|
<span className="text-gray-400 font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={contactInfo.currentITSituation}
|
||||||
|
onChange={(e) => onUpdateContact({ currentITSituation: e.target.value })}
|
||||||
|
placeholder="Tell us about your current IT setup and any challenges you're facing..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-2.5 rounded-lg border border-gray-300 text-[#333d49] placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#fe7400]/20 focus:border-[#fe7400] transition-all duration-200 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Preference */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-sm font-medium text-[#333d49]">
|
||||||
|
Preferred Contact Method
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{contactPreferences.map((pref) => (
|
||||||
|
<label
|
||||||
|
key={pref.id}
|
||||||
|
className="flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="contactPreference"
|
||||||
|
value={pref.id}
|
||||||
|
checked={contactInfo.contactPreference === pref.id}
|
||||||
|
onChange={() => onSetContactPreference(pref.id as ContactPreference)}
|
||||||
|
className="w-4 h-4 text-[#fe7400] border-gray-300 focus:ring-[#fe7400]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">{pref.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terms Checkbox */}
|
||||||
|
<div className="space-y-2 pt-4">
|
||||||
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={contactInfo.agreedToTerms}
|
||||||
|
onChange={(e) => {
|
||||||
|
onSetAgreedToTerms(e.target.checked);
|
||||||
|
handleBlur('agreedToTerms');
|
||||||
|
}}
|
||||||
|
className="w-5 h-5 mt-0.5 text-[#fe7400] border-gray-300 rounded focus:ring-[#fe7400]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
I agree to receive communications about my quote and understand that I can
|
||||||
|
unsubscribe at any time. I have read and agree to the{' '}
|
||||||
|
<a href="/privacy" className="text-[#fe7400] hover:underline">
|
||||||
|
Privacy Policy
|
||||||
|
</a>{' '}
|
||||||
|
and{' '}
|
||||||
|
<a href="/terms" className="text-[#fe7400] hover:underline">
|
||||||
|
Terms of Service
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{touched.agreedToTerms && errors.agreedToTerms && (
|
||||||
|
<p className="text-sm text-red-500 ml-8">{errors.agreedToTerms}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="pt-6"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full text-lg py-4"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Submitting...' : 'Submit Quote Request'}
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Trust Indicators */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="mt-8 pt-6 border-t border-gray-200"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<CheckCircle className="w-6 h-6 text-green-500" />
|
||||||
|
<span className="text-sm text-gray-600">No obligation quote</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<CheckCircle className="w-6 h-6 text-green-500" />
|
||||||
|
<span className="text-sm text-gray-600">Response within 24 hours</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<CheckCircle className="w-6 h-6 text-green-500" />
|
||||||
|
<span className="text-sm text-gray-600">Your data is secure</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export { Step1CompanyProfile, type Step1CompanyProfileProps } from './Step1CompanyProfile';
|
||||||
|
export { Step2GPSMonitoring, type Step2GPSMonitoringProps } from './Step2GPSMonitoring';
|
||||||
|
export { Step3SupportPlan, type Step3SupportPlanProps } from './Step3SupportPlan';
|
||||||
|
export { Step4VoIP, type Step4VoIPProps } from './Step4VoIP';
|
||||||
|
export { Step5WebEmail, type Step5WebEmailProps } from './Step5WebEmail';
|
||||||
|
export { Step6Summary, type Step6SummaryProps } from './Step6Summary';
|
||||||
|
export { Step7Contact, type Step7ContactProps } from './Step7Contact';
|
||||||
612
projects/msp-tools/quote-wizard/frontend/src/hooks/useQuote.ts
Normal file
612
projects/msp-tools/quote-wizard/frontend/src/hooks/useQuote.ts
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import type {
|
||||||
|
QuoteData,
|
||||||
|
QuoteResult,
|
||||||
|
QuoteBreakdown,
|
||||||
|
CompanyInfo,
|
||||||
|
GPSSelection,
|
||||||
|
SupportSelection,
|
||||||
|
VoIPSelection,
|
||||||
|
WebHostingSelection,
|
||||||
|
EmailSelection,
|
||||||
|
ContactInfo,
|
||||||
|
GPSTierId,
|
||||||
|
SupportPlanId,
|
||||||
|
BlockTimeId,
|
||||||
|
VoIPTierId,
|
||||||
|
WebHostingTierId,
|
||||||
|
EmailTierId,
|
||||||
|
EmailProvider,
|
||||||
|
Industry,
|
||||||
|
ContactPreference,
|
||||||
|
} from '@/types/quote';
|
||||||
|
import {
|
||||||
|
gpsTiers,
|
||||||
|
equipmentMonitoring,
|
||||||
|
supportPlans,
|
||||||
|
blockTimeOptions,
|
||||||
|
voipTiers,
|
||||||
|
voipHardware,
|
||||||
|
webHostingTiers,
|
||||||
|
emailTiers,
|
||||||
|
} from '@/lib/pricing-data';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial state values
|
||||||
|
*/
|
||||||
|
const initialCompanyInfo: CompanyInfo = {
|
||||||
|
name: '',
|
||||||
|
endpointCount: 10,
|
||||||
|
industry: '',
|
||||||
|
notes: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialGPSSelection: GPSSelection = {
|
||||||
|
tierId: 'pro',
|
||||||
|
endpointCount: 10,
|
||||||
|
includeEquipment: false,
|
||||||
|
equipmentDeviceCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialSupportSelection: SupportSelection = {
|
||||||
|
planId: 'standard',
|
||||||
|
useBlockTime: false,
|
||||||
|
blockTimeId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialVoIPSelection: VoIPSelection = {
|
||||||
|
enabled: false,
|
||||||
|
tierId: 'voip-standard',
|
||||||
|
userCount: 0,
|
||||||
|
hardware: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialWebHostingSelection: WebHostingSelection = {
|
||||||
|
enabled: false,
|
||||||
|
tierId: 'hosting-business',
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialEmailSelection: EmailSelection = {
|
||||||
|
enabled: false,
|
||||||
|
provider: 'm365',
|
||||||
|
tierId: 'm365-standard',
|
||||||
|
mailboxCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialContactInfo: ContactInfo = {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
companyName: '',
|
||||||
|
currentITSituation: '',
|
||||||
|
contactPreference: 'email',
|
||||||
|
agreedToTerms: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook return type
|
||||||
|
*/
|
||||||
|
export interface UseQuoteReturn {
|
||||||
|
quoteData: QuoteData;
|
||||||
|
quoteResult: QuoteResult | null;
|
||||||
|
|
||||||
|
// Company updates
|
||||||
|
updateCompany: (data: Partial<CompanyInfo>) => void;
|
||||||
|
setEndpointCount: (count: number) => void;
|
||||||
|
setIndustry: (industry: Industry | '') => void;
|
||||||
|
|
||||||
|
// GPS updates
|
||||||
|
updateGPS: (data: Partial<GPSSelection>) => void;
|
||||||
|
setGPSTier: (tierId: GPSTierId) => void;
|
||||||
|
setEquipmentEnabled: (enabled: boolean) => void;
|
||||||
|
setEquipmentCount: (count: number) => void;
|
||||||
|
|
||||||
|
// Support updates
|
||||||
|
updateSupport: (data: Partial<SupportSelection>) => void;
|
||||||
|
setSupportPlan: (planId: SupportPlanId) => void;
|
||||||
|
setBlockTimeEnabled: (enabled: boolean) => void;
|
||||||
|
setBlockTime: (blockTimeId: BlockTimeId) => void;
|
||||||
|
|
||||||
|
// VoIP updates
|
||||||
|
updateVoIP: (data: Partial<VoIPSelection>) => void;
|
||||||
|
setVoIPEnabled: (enabled: boolean) => void;
|
||||||
|
setVoIPTier: (tierId: VoIPTierId) => void;
|
||||||
|
setVoIPUserCount: (count: number) => void;
|
||||||
|
addHardware: (hardwareId: string, quantity: number, isRental: boolean) => void;
|
||||||
|
removeHardware: (hardwareId: string) => void;
|
||||||
|
updateHardwareQuantity: (hardwareId: string, quantity: number) => void;
|
||||||
|
|
||||||
|
// Web Hosting updates
|
||||||
|
updateWebHosting: (data: Partial<WebHostingSelection>) => void;
|
||||||
|
setWebHostingEnabled: (enabled: boolean) => void;
|
||||||
|
setWebHostingTier: (tierId: WebHostingTierId) => void;
|
||||||
|
|
||||||
|
// Email updates
|
||||||
|
updateEmail: (data: Partial<EmailSelection>) => void;
|
||||||
|
setEmailEnabled: (enabled: boolean) => void;
|
||||||
|
setEmailProvider: (provider: EmailProvider) => void;
|
||||||
|
setEmailTier: (tierId: EmailTierId) => void;
|
||||||
|
setMailboxCount: (count: number) => void;
|
||||||
|
|
||||||
|
// Contact updates
|
||||||
|
updateContact: (data: Partial<ContactInfo>) => void;
|
||||||
|
setContactPreference: (preference: ContactPreference) => void;
|
||||||
|
setAgreedToTerms: (agreed: boolean) => void;
|
||||||
|
|
||||||
|
// Calculations
|
||||||
|
calculateQuote: () => QuoteResult;
|
||||||
|
getGPSMonthly: () => number;
|
||||||
|
getSupportMonthly: () => number;
|
||||||
|
getVoIPMonthly: () => number;
|
||||||
|
getWebHostingMonthly: () => number;
|
||||||
|
getEmailMonthly: () => number;
|
||||||
|
getVoIPOneTime: () => number;
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
resetQuote: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quote calculation and state management hook
|
||||||
|
*/
|
||||||
|
export function useQuote(): UseQuoteReturn {
|
||||||
|
const [company, setCompany] = useState<CompanyInfo>(initialCompanyInfo);
|
||||||
|
const [gps, setGPS] = useState<GPSSelection>(initialGPSSelection);
|
||||||
|
const [support, setSupport] = useState<SupportSelection>(initialSupportSelection);
|
||||||
|
const [voip, setVoIP] = useState<VoIPSelection>(initialVoIPSelection);
|
||||||
|
const [webHosting, setWebHosting] = useState<WebHostingSelection>(initialWebHostingSelection);
|
||||||
|
const [email, setEmail] = useState<EmailSelection>(initialEmailSelection);
|
||||||
|
const [contact, setContact] = useState<ContactInfo>(initialContactInfo);
|
||||||
|
const [quoteResult, setQuoteResult] = useState<QuoteResult | null>(null);
|
||||||
|
|
||||||
|
// Combined quote data
|
||||||
|
const quoteData: QuoteData = useMemo(
|
||||||
|
() => ({
|
||||||
|
company,
|
||||||
|
gps,
|
||||||
|
support,
|
||||||
|
voip,
|
||||||
|
webHosting,
|
||||||
|
email,
|
||||||
|
contact,
|
||||||
|
}),
|
||||||
|
[company, gps, support, voip, webHosting, email, contact]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Company Updates
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const updateCompany = useCallback((data: Partial<CompanyInfo>) => {
|
||||||
|
setCompany((prev) => {
|
||||||
|
const updated = { ...prev, ...data };
|
||||||
|
// Sync endpoint count with GPS selection
|
||||||
|
if (data.endpointCount !== undefined) {
|
||||||
|
setGPS((gpsState) => ({ ...gpsState, endpointCount: data.endpointCount as number }));
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setEndpointCount = useCallback((count: number) => {
|
||||||
|
const validCount = Math.max(1, count);
|
||||||
|
setCompany((prev) => ({ ...prev, endpointCount: validCount }));
|
||||||
|
setGPS((prev) => ({ ...prev, endpointCount: validCount }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setIndustry = useCallback((industry: Industry | '') => {
|
||||||
|
setCompany((prev) => ({ ...prev, industry }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GPS Updates
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const updateGPS = useCallback((data: Partial<GPSSelection>) => {
|
||||||
|
setGPS((prev) => ({ ...prev, ...data }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setGPSTier = useCallback((tierId: GPSTierId) => {
|
||||||
|
setGPS((prev) => ({ ...prev, tierId }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setEquipmentEnabled = useCallback((enabled: boolean) => {
|
||||||
|
setGPS((prev) => ({
|
||||||
|
...prev,
|
||||||
|
includeEquipment: enabled,
|
||||||
|
equipmentDeviceCount: enabled ? Math.max(prev.equipmentDeviceCount, 1) : 0,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setEquipmentCount = useCallback((count: number) => {
|
||||||
|
setGPS((prev) => ({ ...prev, equipmentDeviceCount: Math.max(0, count) }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Support Updates
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const updateSupport = useCallback((data: Partial<SupportSelection>) => {
|
||||||
|
setSupport((prev) => ({ ...prev, ...data }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSupportPlan = useCallback((planId: SupportPlanId) => {
|
||||||
|
setSupport((prev) => ({ ...prev, planId }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setBlockTimeEnabled = useCallback((enabled: boolean) => {
|
||||||
|
setSupport((prev) => ({
|
||||||
|
...prev,
|
||||||
|
useBlockTime: enabled,
|
||||||
|
blockTimeId: enabled ? (prev.blockTimeId || 'block-10') : null,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setBlockTime = useCallback((blockTimeId: BlockTimeId) => {
|
||||||
|
setSupport((prev) => ({ ...prev, blockTimeId, useBlockTime: true }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VoIP Updates
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const updateVoIP = useCallback((data: Partial<VoIPSelection>) => {
|
||||||
|
setVoIP((prev) => ({ ...prev, ...data }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setVoIPEnabled = useCallback((enabled: boolean) => {
|
||||||
|
setVoIP((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabled,
|
||||||
|
userCount: enabled ? Math.max(prev.userCount, 1) : 0,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setVoIPTier = useCallback((tierId: VoIPTierId) => {
|
||||||
|
setVoIP((prev) => ({ ...prev, tierId }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setVoIPUserCount = useCallback((count: number) => {
|
||||||
|
setVoIP((prev) => ({ ...prev, userCount: Math.max(0, count) }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addHardware = useCallback((hardwareId: string, quantity: number, isRental: boolean) => {
|
||||||
|
setVoIP((prev) => {
|
||||||
|
const existing = prev.hardware.find((h) => h.hardwareId === hardwareId);
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
hardware: prev.hardware.map((h) =>
|
||||||
|
h.hardwareId === hardwareId ? { ...h, quantity, isRental } : h
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
hardware: [...prev.hardware, { hardwareId, quantity, isRental }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeHardware = useCallback((hardwareId: string) => {
|
||||||
|
setVoIP((prev) => ({
|
||||||
|
...prev,
|
||||||
|
hardware: prev.hardware.filter((h) => h.hardwareId !== hardwareId),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateHardwareQuantity = useCallback((hardwareId: string, quantity: number) => {
|
||||||
|
setVoIP((prev) => ({
|
||||||
|
...prev,
|
||||||
|
hardware: prev.hardware.map((h) =>
|
||||||
|
h.hardwareId === hardwareId ? { ...h, quantity: Math.max(0, quantity) } : h
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Web Hosting Updates
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const updateWebHosting = useCallback((data: Partial<WebHostingSelection>) => {
|
||||||
|
setWebHosting((prev) => ({ ...prev, ...data }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setWebHostingEnabled = useCallback((enabled: boolean) => {
|
||||||
|
setWebHosting((prev) => ({ ...prev, enabled }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setWebHostingTier = useCallback((tierId: WebHostingTierId) => {
|
||||||
|
setWebHosting((prev) => ({ ...prev, tierId }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Email Updates
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const updateEmail = useCallback((data: Partial<EmailSelection>) => {
|
||||||
|
setEmail((prev) => ({ ...prev, ...data }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setEmailEnabled = useCallback((enabled: boolean) => {
|
||||||
|
setEmail((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabled,
|
||||||
|
mailboxCount: enabled ? Math.max(prev.mailboxCount, 1) : 0,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setEmailProvider = useCallback((provider: EmailProvider) => {
|
||||||
|
setEmail((prev) => {
|
||||||
|
// Set default tier for provider
|
||||||
|
const defaultTier = provider === 'm365' ? 'm365-standard' : 'whm-standard';
|
||||||
|
return { ...prev, provider, tierId: defaultTier as EmailTierId };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setEmailTier = useCallback((tierId: EmailTierId) => {
|
||||||
|
setEmail((prev) => ({ ...prev, tierId }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setMailboxCount = useCallback((count: number) => {
|
||||||
|
setEmail((prev) => ({ ...prev, mailboxCount: Math.max(0, count) }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Contact Updates
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const updateContact = useCallback((data: Partial<ContactInfo>) => {
|
||||||
|
setContact((prev) => ({ ...prev, ...data }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setContactPreference = useCallback((preference: ContactPreference) => {
|
||||||
|
setContact((prev) => ({ ...prev, contactPreference: preference }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setAgreedToTerms = useCallback((agreed: boolean) => {
|
||||||
|
setContact((prev) => ({ ...prev, agreedToTerms: agreed }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Calculation Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const getGPSMonthly = useCallback((): number => {
|
||||||
|
const tier = gpsTiers.find((t) => t.id === gps.tierId);
|
||||||
|
if (!tier) return 0;
|
||||||
|
|
||||||
|
let total = tier.pricePerEndpoint * gps.endpointCount;
|
||||||
|
|
||||||
|
if (gps.includeEquipment && gps.equipmentDeviceCount > 0) {
|
||||||
|
const additionalDevices = Math.max(0, gps.equipmentDeviceCount - equipmentMonitoring.baseDevices);
|
||||||
|
total += equipmentMonitoring.basePrice + (additionalDevices * equipmentMonitoring.additionalDevicePrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}, [gps]);
|
||||||
|
|
||||||
|
const getSupportMonthly = useCallback((): number => {
|
||||||
|
const plan = supportPlans.find((p) => p.id === support.planId);
|
||||||
|
if (!plan) return 0;
|
||||||
|
|
||||||
|
let total = plan.monthlyPrice;
|
||||||
|
|
||||||
|
if (support.useBlockTime && support.blockTimeId) {
|
||||||
|
const blockTime = blockTimeOptions.find((b) => b.id === support.blockTimeId);
|
||||||
|
if (blockTime) {
|
||||||
|
total += blockTime.price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}, [support]);
|
||||||
|
|
||||||
|
const getVoIPMonthly = useCallback((): number => {
|
||||||
|
if (!voip.enabled) return 0;
|
||||||
|
|
||||||
|
const tier = voipTiers.find((t) => t.id === voip.tierId);
|
||||||
|
if (!tier) return 0;
|
||||||
|
|
||||||
|
let total = tier.pricePerUser * voip.userCount;
|
||||||
|
|
||||||
|
// Add rental hardware costs
|
||||||
|
voip.hardware.forEach((hw) => {
|
||||||
|
if (hw.isRental) {
|
||||||
|
const hardware = voipHardware.find((h) => h.id === hw.hardwareId);
|
||||||
|
if (hardware) {
|
||||||
|
total += hardware.monthlyRental * hw.quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}, [voip]);
|
||||||
|
|
||||||
|
const getVoIPOneTime = useCallback((): number => {
|
||||||
|
if (!voip.enabled) return 0;
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
// Add purchased hardware costs
|
||||||
|
voip.hardware.forEach((hw) => {
|
||||||
|
if (!hw.isRental) {
|
||||||
|
const hardware = voipHardware.find((h) => h.id === hw.hardwareId);
|
||||||
|
if (hardware) {
|
||||||
|
total += hardware.oneTimePrice * hw.quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}, [voip]);
|
||||||
|
|
||||||
|
const getWebHostingMonthly = useCallback((): number => {
|
||||||
|
if (!webHosting.enabled) return 0;
|
||||||
|
|
||||||
|
const tier = webHostingTiers.find((t) => t.id === webHosting.tierId);
|
||||||
|
return tier ? tier.monthlyPrice : 0;
|
||||||
|
}, [webHosting]);
|
||||||
|
|
||||||
|
const getEmailMonthly = useCallback((): number => {
|
||||||
|
if (!email.enabled) return 0;
|
||||||
|
|
||||||
|
const tier = emailTiers.find((t) => t.id === email.tierId);
|
||||||
|
return tier ? tier.pricePerMailbox * email.mailboxCount : 0;
|
||||||
|
}, [email]);
|
||||||
|
|
||||||
|
const calculateQuote = useCallback((): QuoteResult => {
|
||||||
|
const gpsMonthly = getGPSMonthly();
|
||||||
|
const supportMonthly = getSupportMonthly();
|
||||||
|
const voipMonthly = getVoIPMonthly();
|
||||||
|
const voipOneTime = getVoIPOneTime();
|
||||||
|
const webHostingMonthly = getWebHostingMonthly();
|
||||||
|
const emailMonthly = getEmailMonthly();
|
||||||
|
|
||||||
|
// Calculate GPS breakdown
|
||||||
|
const gpsTier = gpsTiers.find((t) => t.id === gps.tierId);
|
||||||
|
const gpsMonitoring = gpsTier ? gpsTier.pricePerEndpoint * gps.endpointCount : 0;
|
||||||
|
let gpsEquipment = 0;
|
||||||
|
if (gps.includeEquipment && gps.equipmentDeviceCount > 0) {
|
||||||
|
const additionalDevices = Math.max(0, gps.equipmentDeviceCount - equipmentMonitoring.baseDevices);
|
||||||
|
gpsEquipment = equipmentMonitoring.basePrice + (additionalDevices * equipmentMonitoring.additionalDevicePrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate support breakdown
|
||||||
|
const supportPlan = supportPlans.find((p) => p.id === support.planId);
|
||||||
|
const supportPlanCost = supportPlan ? supportPlan.monthlyPrice : 0;
|
||||||
|
let supportBlockTime = 0;
|
||||||
|
if (support.useBlockTime && support.blockTimeId) {
|
||||||
|
const blockTime = blockTimeOptions.find((b) => b.id === support.blockTimeId);
|
||||||
|
if (blockTime) {
|
||||||
|
supportBlockTime = blockTime.price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate VoIP breakdown
|
||||||
|
const voipTier = voipTiers.find((t) => t.id === voip.tierId);
|
||||||
|
const voipService = voip.enabled && voipTier ? voipTier.pricePerUser * voip.userCount : 0;
|
||||||
|
let voipHardwareMonthly = 0;
|
||||||
|
if (voip.enabled) {
|
||||||
|
voip.hardware.forEach((hw) => {
|
||||||
|
if (hw.isRental) {
|
||||||
|
const hardware = voipHardware.find((h) => h.id === hw.hardwareId);
|
||||||
|
if (hardware) {
|
||||||
|
voipHardwareMonthly += hardware.monthlyRental * hw.quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const breakdown: QuoteBreakdown = {
|
||||||
|
gps: {
|
||||||
|
monitoring: gpsMonitoring,
|
||||||
|
equipment: gpsEquipment,
|
||||||
|
total: gpsMonthly,
|
||||||
|
},
|
||||||
|
support: {
|
||||||
|
plan: supportPlanCost,
|
||||||
|
blockTime: supportBlockTime,
|
||||||
|
total: supportMonthly,
|
||||||
|
},
|
||||||
|
voip: {
|
||||||
|
service: voipService,
|
||||||
|
hardware: voipHardwareMonthly,
|
||||||
|
total: voipMonthly,
|
||||||
|
},
|
||||||
|
webHosting: webHostingMonthly,
|
||||||
|
email: emailMonthly,
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthlyTotal = gpsMonthly + supportMonthly + voipMonthly + webHostingMonthly + emailMonthly;
|
||||||
|
|
||||||
|
const result: QuoteResult = {
|
||||||
|
monthlyTotal,
|
||||||
|
oneTimeTotal: voipOneTime,
|
||||||
|
breakdown,
|
||||||
|
gpsMonthly,
|
||||||
|
supportMonthly,
|
||||||
|
voipMonthly,
|
||||||
|
webHostingMonthly,
|
||||||
|
emailMonthly,
|
||||||
|
};
|
||||||
|
|
||||||
|
setQuoteResult(result);
|
||||||
|
return result;
|
||||||
|
}, [gps, support, voip, webHosting, email, getGPSMonthly, getSupportMonthly, getVoIPMonthly, getVoIPOneTime, getWebHostingMonthly, getEmailMonthly]);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Reset
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const resetQuote = useCallback(() => {
|
||||||
|
setCompany(initialCompanyInfo);
|
||||||
|
setGPS(initialGPSSelection);
|
||||||
|
setSupport(initialSupportSelection);
|
||||||
|
setVoIP(initialVoIPSelection);
|
||||||
|
setWebHosting(initialWebHostingSelection);
|
||||||
|
setEmail(initialEmailSelection);
|
||||||
|
setContact(initialContactInfo);
|
||||||
|
setQuoteResult(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
quoteData,
|
||||||
|
quoteResult,
|
||||||
|
|
||||||
|
// Company updates
|
||||||
|
updateCompany,
|
||||||
|
setEndpointCount,
|
||||||
|
setIndustry,
|
||||||
|
|
||||||
|
// GPS updates
|
||||||
|
updateGPS,
|
||||||
|
setGPSTier,
|
||||||
|
setEquipmentEnabled,
|
||||||
|
setEquipmentCount,
|
||||||
|
|
||||||
|
// Support updates
|
||||||
|
updateSupport,
|
||||||
|
setSupportPlan,
|
||||||
|
setBlockTimeEnabled,
|
||||||
|
setBlockTime,
|
||||||
|
|
||||||
|
// VoIP updates
|
||||||
|
updateVoIP,
|
||||||
|
setVoIPEnabled,
|
||||||
|
setVoIPTier,
|
||||||
|
setVoIPUserCount,
|
||||||
|
addHardware,
|
||||||
|
removeHardware,
|
||||||
|
updateHardwareQuantity,
|
||||||
|
|
||||||
|
// Web Hosting updates
|
||||||
|
updateWebHosting,
|
||||||
|
setWebHostingEnabled,
|
||||||
|
setWebHostingTier,
|
||||||
|
|
||||||
|
// Email updates
|
||||||
|
updateEmail,
|
||||||
|
setEmailEnabled,
|
||||||
|
setEmailProvider,
|
||||||
|
setEmailTier,
|
||||||
|
setMailboxCount,
|
||||||
|
|
||||||
|
// Contact updates
|
||||||
|
updateContact,
|
||||||
|
setContactPreference,
|
||||||
|
setAgreedToTerms,
|
||||||
|
|
||||||
|
// Calculations
|
||||||
|
calculateQuote,
|
||||||
|
getGPSMonthly,
|
||||||
|
getSupportMonthly,
|
||||||
|
getVoIPMonthly,
|
||||||
|
getWebHostingMonthly,
|
||||||
|
getEmailMonthly,
|
||||||
|
getVoIPOneTime,
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
resetQuote,
|
||||||
|
};
|
||||||
|
}
|
||||||
160
projects/msp-tools/quote-wizard/frontend/src/hooks/useWizard.ts
Normal file
160
projects/msp-tools/quote-wizard/frontend/src/hooks/useWizard.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import type { WizardStep } from '@/types/quote';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wizard steps configuration for the 7-step MSP Quote Wizard
|
||||||
|
*/
|
||||||
|
const WIZARD_STEPS: Omit<WizardStep, 'isComplete' | 'isActive'>[] = [
|
||||||
|
{
|
||||||
|
id: 'company',
|
||||||
|
title: 'Company Profile',
|
||||||
|
description: 'Tell us about your business',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gps',
|
||||||
|
title: 'GPS Monitoring',
|
||||||
|
description: 'Select your monitoring tier',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'support',
|
||||||
|
title: 'Support Plan',
|
||||||
|
description: 'Choose your support level',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'voip',
|
||||||
|
title: 'VoIP Phone System',
|
||||||
|
description: 'Business phone options',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'web-email',
|
||||||
|
title: 'Web & Email',
|
||||||
|
description: 'Hosting and email services',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'summary',
|
||||||
|
title: 'Review Quote',
|
||||||
|
description: 'Review your selections',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'contact',
|
||||||
|
title: 'Get Your Quote',
|
||||||
|
description: 'Submit your information',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface UseWizardReturn {
|
||||||
|
currentStep: number;
|
||||||
|
steps: WizardStep[];
|
||||||
|
totalSteps: number;
|
||||||
|
isFirstStep: boolean;
|
||||||
|
isLastStep: boolean;
|
||||||
|
goToStep: (step: number) => void;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
markStepComplete: (stepIndex: number) => void;
|
||||||
|
markStepIncomplete: (stepIndex: number) => void;
|
||||||
|
resetWizard: () => void;
|
||||||
|
progress: number;
|
||||||
|
canProceed: boolean;
|
||||||
|
setCanProceed: (canProceed: boolean) => void;
|
||||||
|
currentStepId: string;
|
||||||
|
getStepByIndex: (index: number) => WizardStep | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWizard(): UseWizardReturn {
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
|
||||||
|
const [canProceed, setCanProceed] = useState(true);
|
||||||
|
|
||||||
|
const totalSteps = WIZARD_STEPS.length;
|
||||||
|
const isFirstStep = currentStep === 0;
|
||||||
|
const isLastStep = currentStep === totalSteps - 1;
|
||||||
|
|
||||||
|
const steps: WizardStep[] = useMemo(() => {
|
||||||
|
return WIZARD_STEPS.map((step, index) => ({
|
||||||
|
...step,
|
||||||
|
isComplete: completedSteps.has(index),
|
||||||
|
isActive: index === currentStep,
|
||||||
|
}));
|
||||||
|
}, [currentStep, completedSteps]);
|
||||||
|
|
||||||
|
const currentStepId = useMemo(() => {
|
||||||
|
return WIZARD_STEPS[currentStep]?.id || '';
|
||||||
|
}, [currentStep]);
|
||||||
|
|
||||||
|
const progress = useMemo(() => {
|
||||||
|
// Progress based on current step position (0 to 100)
|
||||||
|
return Math.round((currentStep / (totalSteps - 1)) * 100);
|
||||||
|
}, [currentStep, totalSteps]);
|
||||||
|
|
||||||
|
const goToStep = useCallback(
|
||||||
|
(step: number) => {
|
||||||
|
if (step >= 0 && step < totalSteps) {
|
||||||
|
// Allow going back to any previous step
|
||||||
|
// Only allow going forward to completed steps or the next step
|
||||||
|
if (step <= currentStep || completedSteps.has(step - 1) || step === currentStep + 1) {
|
||||||
|
setCurrentStep(step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[totalSteps, currentStep, completedSteps]
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextStep = useCallback(() => {
|
||||||
|
if (!isLastStep && canProceed) {
|
||||||
|
// Mark current step as complete when moving forward
|
||||||
|
setCompletedSteps((prev) => new Set(prev).add(currentStep));
|
||||||
|
setCurrentStep((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
}, [currentStep, isLastStep, canProceed]);
|
||||||
|
|
||||||
|
const prevStep = useCallback(() => {
|
||||||
|
if (!isFirstStep) {
|
||||||
|
setCurrentStep((prev) => prev - 1);
|
||||||
|
}
|
||||||
|
}, [isFirstStep]);
|
||||||
|
|
||||||
|
const markStepComplete = useCallback((stepIndex: number) => {
|
||||||
|
setCompletedSteps((prev) => new Set(prev).add(stepIndex));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markStepIncomplete = useCallback((stepIndex: number) => {
|
||||||
|
setCompletedSteps((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(stepIndex);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetWizard = useCallback(() => {
|
||||||
|
setCurrentStep(0);
|
||||||
|
setCompletedSteps(new Set());
|
||||||
|
setCanProceed(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getStepByIndex = useCallback(
|
||||||
|
(index: number): WizardStep | undefined => {
|
||||||
|
return steps[index];
|
||||||
|
},
|
||||||
|
[steps]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentStep,
|
||||||
|
steps,
|
||||||
|
totalSteps,
|
||||||
|
isFirstStep,
|
||||||
|
isLastStep,
|
||||||
|
goToStep,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
markStepComplete,
|
||||||
|
markStepIncomplete,
|
||||||
|
resetWizard,
|
||||||
|
progress,
|
||||||
|
canProceed,
|
||||||
|
setCanProceed,
|
||||||
|
currentStepId,
|
||||||
|
getStepByIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
62
projects/msp-tools/quote-wizard/frontend/src/index.css
Normal file
62
projects/msp-tools/quote-wizard/frontend/src/index.css
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700&display=swap');
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-primary: #333d49;
|
||||||
|
--color-accent: #fe7400;
|
||||||
|
--color-navy: #113559;
|
||||||
|
--color-gray-600: #4d4d4d;
|
||||||
|
|
||||||
|
--font-family-lexend: 'Lexend', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: 'Lexend', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Lexend', sans-serif;
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #333d49;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #333d49;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #113559;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus styles for accessibility */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid #fe7400;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection color */
|
||||||
|
::selection {
|
||||||
|
background-color: #fe7400;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
84
projects/msp-tools/quote-wizard/frontend/src/lib/api.ts
Normal file
84
projects/msp-tools/quote-wizard/frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import type { QuoteData, QuoteResult } from '@/types/quote';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API client for MSP Quote Wizard
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8001';
|
||||||
|
|
||||||
|
export const apiClient = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor for adding auth token
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem('quote_wizard_token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor for error handling
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('quote_wizard_token');
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API endpoints
|
||||||
|
*/
|
||||||
|
export const quoteApi = {
|
||||||
|
/**
|
||||||
|
* Calculate quote based on provided data
|
||||||
|
*/
|
||||||
|
calculateQuote: async (data: QuoteData): Promise<QuoteResult> => {
|
||||||
|
const response = await apiClient.post<QuoteResult>('/api/quotes/calculate', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save quote for later retrieval
|
||||||
|
*/
|
||||||
|
saveQuote: async (data: QuoteData & { email: string }): Promise<{ quoteId: string }> => {
|
||||||
|
const response = await apiClient.post<{ quoteId: string }>('/api/quotes/save', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve saved quote by ID
|
||||||
|
*/
|
||||||
|
getQuote: async (quoteId: string): Promise<QuoteData & QuoteResult> => {
|
||||||
|
const response = await apiClient.get<QuoteData & QuoteResult>(`/api/quotes/${quoteId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit quote request for sales follow-up
|
||||||
|
*/
|
||||||
|
submitQuoteRequest: async (data: QuoteData & {
|
||||||
|
contactInfo: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
}): Promise<{ success: boolean; message: string }> => {
|
||||||
|
const response = await apiClient.post('/api/quotes/submit', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
423
projects/msp-tools/quote-wizard/frontend/src/lib/pricing-data.ts
Normal file
423
projects/msp-tools/quote-wizard/frontend/src/lib/pricing-data.ts
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
import type {
|
||||||
|
GPSTier,
|
||||||
|
SupportPlan,
|
||||||
|
BlockTimeOption,
|
||||||
|
VoIPTier,
|
||||||
|
WebHostingTier,
|
||||||
|
EmailTier,
|
||||||
|
VoIPHardware
|
||||||
|
} from '@/types/quote';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GPS Monitoring Tiers
|
||||||
|
*/
|
||||||
|
export const gpsTiers: GPSTier[] = [
|
||||||
|
{
|
||||||
|
id: 'basic',
|
||||||
|
name: 'Basic',
|
||||||
|
description: 'Essential monitoring for small environments',
|
||||||
|
pricePerEndpoint: 19,
|
||||||
|
features: [
|
||||||
|
'Remote monitoring & management',
|
||||||
|
'8x5 help desk support',
|
||||||
|
'Patch management',
|
||||||
|
'Basic antivirus protection',
|
||||||
|
'Monthly health reports',
|
||||||
|
],
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pro',
|
||||||
|
name: 'Pro',
|
||||||
|
description: 'Comprehensive protection for growing businesses',
|
||||||
|
pricePerEndpoint: 26,
|
||||||
|
features: [
|
||||||
|
'Everything in Basic, plus:',
|
||||||
|
'24x7 help desk support',
|
||||||
|
'Advanced endpoint protection',
|
||||||
|
'Backup & disaster recovery',
|
||||||
|
'Network monitoring',
|
||||||
|
'Quarterly business reviews',
|
||||||
|
],
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'advanced',
|
||||||
|
name: 'Advanced',
|
||||||
|
description: 'Enterprise-grade security and compliance',
|
||||||
|
pricePerEndpoint: 39,
|
||||||
|
features: [
|
||||||
|
'Everything in Pro, plus:',
|
||||||
|
'Dedicated account manager',
|
||||||
|
'Virtual CIO services',
|
||||||
|
'Compliance management',
|
||||||
|
'Security awareness training',
|
||||||
|
'Advanced threat detection',
|
||||||
|
'Priority response SLA',
|
||||||
|
],
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Equipment monitoring pricing
|
||||||
|
*/
|
||||||
|
export const equipmentMonitoring = {
|
||||||
|
basePrice: 25, // Up to 10 devices
|
||||||
|
baseDevices: 10,
|
||||||
|
additionalDevicePrice: 3, // Per additional device
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Support Plans
|
||||||
|
*/
|
||||||
|
export const supportPlans: SupportPlan[] = [
|
||||||
|
{
|
||||||
|
id: 'essential',
|
||||||
|
name: 'Essential',
|
||||||
|
description: 'Basic support for small teams',
|
||||||
|
monthlyPrice: 200,
|
||||||
|
includedHours: 2,
|
||||||
|
effectiveHourlyRate: 100,
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'standard',
|
||||||
|
name: 'Standard',
|
||||||
|
description: 'Balanced support for growing businesses',
|
||||||
|
monthlyPrice: 380,
|
||||||
|
includedHours: 4,
|
||||||
|
effectiveHourlyRate: 95,
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'premium',
|
||||||
|
name: 'Premium',
|
||||||
|
description: 'Enhanced support with faster response',
|
||||||
|
monthlyPrice: 540,
|
||||||
|
includedHours: 6,
|
||||||
|
effectiveHourlyRate: 90,
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'priority',
|
||||||
|
name: 'Priority',
|
||||||
|
description: 'Top-tier support with dedicated resources',
|
||||||
|
monthlyPrice: 850,
|
||||||
|
includedHours: 10,
|
||||||
|
effectiveHourlyRate: 85,
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block Time Options
|
||||||
|
*/
|
||||||
|
export const blockTimeOptions: BlockTimeOption[] = [
|
||||||
|
{
|
||||||
|
id: 'block-10',
|
||||||
|
hours: 10,
|
||||||
|
price: 1500,
|
||||||
|
effectiveHourlyRate: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-20',
|
||||||
|
hours: 20,
|
||||||
|
price: 2600,
|
||||||
|
effectiveHourlyRate: 130,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block-30',
|
||||||
|
hours: 30,
|
||||||
|
price: 3000,
|
||||||
|
effectiveHourlyRate: 100,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VoIP Tiers
|
||||||
|
*/
|
||||||
|
export const voipTiers: VoIPTier[] = [
|
||||||
|
{
|
||||||
|
id: 'voip-basic',
|
||||||
|
name: 'Basic',
|
||||||
|
description: 'Essential phone features for small teams',
|
||||||
|
pricePerUser: 22,
|
||||||
|
features: [
|
||||||
|
'Unlimited local & long distance',
|
||||||
|
'Voicemail to email',
|
||||||
|
'Basic auto-attendant',
|
||||||
|
'Mobile app',
|
||||||
|
],
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'voip-standard',
|
||||||
|
name: 'Standard',
|
||||||
|
description: 'Full-featured business phone system',
|
||||||
|
pricePerUser: 28,
|
||||||
|
features: [
|
||||||
|
'Everything in Basic, plus:',
|
||||||
|
'Video conferencing',
|
||||||
|
'Ring groups',
|
||||||
|
'Call recording',
|
||||||
|
'CRM integration',
|
||||||
|
],
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'voip-pro',
|
||||||
|
name: 'Pro',
|
||||||
|
description: 'Advanced features for power users',
|
||||||
|
pricePerUser: 35,
|
||||||
|
features: [
|
||||||
|
'Everything in Standard, plus:',
|
||||||
|
'Advanced analytics',
|
||||||
|
'Custom IVR',
|
||||||
|
'Supervisor dashboard',
|
||||||
|
'API access',
|
||||||
|
],
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'voip-callcenter',
|
||||||
|
name: 'Call Center',
|
||||||
|
description: 'Full call center capabilities',
|
||||||
|
pricePerUser: 55,
|
||||||
|
features: [
|
||||||
|
'Everything in Pro, plus:',
|
||||||
|
'Queue management',
|
||||||
|
'Wallboards',
|
||||||
|
'Agent scoring',
|
||||||
|
'Predictive dialing',
|
||||||
|
'Real-time monitoring',
|
||||||
|
],
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VoIP Hardware Options
|
||||||
|
*/
|
||||||
|
export const voipHardware: VoIPHardware[] = [
|
||||||
|
{
|
||||||
|
id: 'yealink-t33g',
|
||||||
|
name: 'Yealink T33G',
|
||||||
|
description: 'Entry-level IP phone',
|
||||||
|
oneTimePrice: 89,
|
||||||
|
monthlyRental: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'yealink-t54w',
|
||||||
|
name: 'Yealink T54W',
|
||||||
|
description: 'Mid-range color screen phone',
|
||||||
|
oneTimePrice: 169,
|
||||||
|
monthlyRental: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'yealink-t58a',
|
||||||
|
name: 'Yealink T58A',
|
||||||
|
description: 'Executive phone with video',
|
||||||
|
oneTimePrice: 299,
|
||||||
|
monthlyRental: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'headset-basic',
|
||||||
|
name: 'USB Headset',
|
||||||
|
description: 'Basic USB headset',
|
||||||
|
oneTimePrice: 45,
|
||||||
|
monthlyRental: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'headset-wireless',
|
||||||
|
name: 'Wireless Headset',
|
||||||
|
description: 'Premium wireless headset',
|
||||||
|
oneTimePrice: 149,
|
||||||
|
monthlyRental: 7,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web Hosting Tiers
|
||||||
|
*/
|
||||||
|
export const webHostingTiers: WebHostingTier[] = [
|
||||||
|
{
|
||||||
|
id: 'hosting-starter',
|
||||||
|
name: 'Starter',
|
||||||
|
description: 'Perfect for simple business sites',
|
||||||
|
monthlyPrice: 15,
|
||||||
|
storage: '5GB',
|
||||||
|
sites: 1,
|
||||||
|
features: [
|
||||||
|
'5GB SSD storage',
|
||||||
|
'1 website',
|
||||||
|
'Free SSL certificate',
|
||||||
|
'Daily backups',
|
||||||
|
'Email support',
|
||||||
|
],
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hosting-business',
|
||||||
|
name: 'Business',
|
||||||
|
description: 'Great for multiple sites and more traffic',
|
||||||
|
monthlyPrice: 35,
|
||||||
|
storage: '25GB',
|
||||||
|
sites: 5,
|
||||||
|
features: [
|
||||||
|
'25GB SSD storage',
|
||||||
|
'5 websites',
|
||||||
|
'Free SSL certificates',
|
||||||
|
'Daily backups',
|
||||||
|
'Staging environment',
|
||||||
|
'Priority support',
|
||||||
|
],
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hosting-commerce',
|
||||||
|
name: 'Commerce',
|
||||||
|
description: 'E-commerce ready with unlimited sites',
|
||||||
|
monthlyPrice: 65,
|
||||||
|
storage: '50GB',
|
||||||
|
sites: -1, // Unlimited
|
||||||
|
features: [
|
||||||
|
'50GB SSD storage',
|
||||||
|
'Unlimited websites',
|
||||||
|
'Free SSL certificates',
|
||||||
|
'Real-time backups',
|
||||||
|
'CDN included',
|
||||||
|
'PCI compliance',
|
||||||
|
'Dedicated support',
|
||||||
|
],
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email Tiers
|
||||||
|
*/
|
||||||
|
export const emailTiers: EmailTier[] = [
|
||||||
|
// WHM (Self-hosted) Options
|
||||||
|
{
|
||||||
|
id: 'whm-basic',
|
||||||
|
name: 'WHM Basic',
|
||||||
|
description: 'Self-hosted email basics',
|
||||||
|
pricePerMailbox: 2,
|
||||||
|
provider: 'whm',
|
||||||
|
storage: '5GB',
|
||||||
|
features: [
|
||||||
|
'5GB storage per mailbox',
|
||||||
|
'Webmail access',
|
||||||
|
'IMAP/POP3/SMTP',
|
||||||
|
'Spam filtering',
|
||||||
|
],
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'whm-standard',
|
||||||
|
name: 'WHM Standard',
|
||||||
|
description: 'Enhanced self-hosted email',
|
||||||
|
pricePerMailbox: 4,
|
||||||
|
provider: 'whm',
|
||||||
|
storage: '10GB',
|
||||||
|
features: [
|
||||||
|
'10GB storage per mailbox',
|
||||||
|
'Webmail access',
|
||||||
|
'IMAP/POP3/SMTP',
|
||||||
|
'Advanced spam filtering',
|
||||||
|
'Email aliases',
|
||||||
|
],
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'whm-pro',
|
||||||
|
name: 'WHM Pro',
|
||||||
|
description: 'Professional self-hosted email',
|
||||||
|
pricePerMailbox: 10,
|
||||||
|
provider: 'whm',
|
||||||
|
storage: '25GB',
|
||||||
|
features: [
|
||||||
|
'25GB storage per mailbox',
|
||||||
|
'Webmail access',
|
||||||
|
'IMAP/POP3/SMTP',
|
||||||
|
'Premium spam filtering',
|
||||||
|
'Email archiving',
|
||||||
|
'Shared calendars',
|
||||||
|
],
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
// Microsoft 365 Options
|
||||||
|
{
|
||||||
|
id: 'm365-basic',
|
||||||
|
name: 'M365 Basic',
|
||||||
|
description: 'Microsoft 365 essentials',
|
||||||
|
pricePerMailbox: 7,
|
||||||
|
provider: 'm365',
|
||||||
|
storage: '50GB',
|
||||||
|
features: [
|
||||||
|
'50GB mailbox',
|
||||||
|
'Outlook web access',
|
||||||
|
'Mobile apps',
|
||||||
|
'OneDrive 1TB',
|
||||||
|
'Microsoft Teams',
|
||||||
|
],
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'm365-standard',
|
||||||
|
name: 'M365 Standard',
|
||||||
|
description: 'Full Microsoft 365 experience',
|
||||||
|
pricePerMailbox: 14,
|
||||||
|
provider: 'm365',
|
||||||
|
storage: '50GB',
|
||||||
|
features: [
|
||||||
|
'50GB mailbox',
|
||||||
|
'Desktop Office apps',
|
||||||
|
'OneDrive 1TB',
|
||||||
|
'Microsoft Teams',
|
||||||
|
'SharePoint',
|
||||||
|
'Bookings',
|
||||||
|
],
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'm365-premium',
|
||||||
|
name: 'M365 Premium',
|
||||||
|
description: 'Enterprise security and compliance',
|
||||||
|
pricePerMailbox: 24,
|
||||||
|
provider: 'm365',
|
||||||
|
storage: '100GB',
|
||||||
|
features: [
|
||||||
|
'100GB mailbox',
|
||||||
|
'Everything in Standard',
|
||||||
|
'Advanced security',
|
||||||
|
'Device management',
|
||||||
|
'Azure AD Premium',
|
||||||
|
'Data loss prevention',
|
||||||
|
],
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Industry options for company info
|
||||||
|
*/
|
||||||
|
export const industries = [
|
||||||
|
'Healthcare',
|
||||||
|
'Legal',
|
||||||
|
'Finance',
|
||||||
|
'Manufacturing',
|
||||||
|
'Retail',
|
||||||
|
'Professional Services',
|
||||||
|
'Other',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact preference options
|
||||||
|
*/
|
||||||
|
export const contactPreferences = [
|
||||||
|
{ id: 'email', label: 'Email' },
|
||||||
|
{ id: 'phone', label: 'Phone' },
|
||||||
|
{ id: 'either', label: 'Either' },
|
||||||
|
] as const;
|
||||||
69
projects/msp-tools/quote-wizard/frontend/src/lib/utils.ts
Normal file
69
projects/msp-tools/quote-wizard/frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to merge class names
|
||||||
|
* Combines clsx for conditional classes
|
||||||
|
*/
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return clsx(inputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format currency value
|
||||||
|
*/
|
||||||
|
export function formatCurrency(value: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format number with commas
|
||||||
|
*/
|
||||||
|
export function formatNumber(value: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US').format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce function
|
||||||
|
*/
|
||||||
|
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||||
|
func: T,
|
||||||
|
wait: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
return function executedFunction(...args: Parameters<T>) {
|
||||||
|
const later = () => {
|
||||||
|
timeout = null;
|
||||||
|
func(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (timeout !== null) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total device count
|
||||||
|
*/
|
||||||
|
export function getTotalDevices(devices: {
|
||||||
|
workstations: number;
|
||||||
|
laptops: number;
|
||||||
|
servers: number;
|
||||||
|
networkDevices: number;
|
||||||
|
mobileDevices: number;
|
||||||
|
}): number {
|
||||||
|
return (
|
||||||
|
devices.workstations +
|
||||||
|
devices.laptops +
|
||||||
|
devices.servers +
|
||||||
|
devices.networkDevices +
|
||||||
|
devices.mobileDevices
|
||||||
|
);
|
||||||
|
}
|
||||||
22
projects/msp-tools/quote-wizard/frontend/src/main.tsx
Normal file
22
projects/msp-tools/quote-wizard/frontend/src/main.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
269
projects/msp-tools/quote-wizard/frontend/src/types/quote.ts
Normal file
269
projects/msp-tools/quote-wizard/frontend/src/types/quote.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* MSP Quote Wizard Types
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GPS Monitoring Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type GPSTierId = 'basic' | 'pro' | 'advanced';
|
||||||
|
|
||||||
|
export interface GPSTier {
|
||||||
|
id: GPSTierId;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
pricePerEndpoint: number;
|
||||||
|
features: string[];
|
||||||
|
recommended: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GPSSelection {
|
||||||
|
tierId: GPSTierId;
|
||||||
|
endpointCount: number;
|
||||||
|
includeEquipment: boolean;
|
||||||
|
equipmentDeviceCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Support Plan Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type SupportPlanId = 'essential' | 'standard' | 'premium' | 'priority';
|
||||||
|
export type BlockTimeId = 'block-10' | 'block-20' | 'block-30';
|
||||||
|
|
||||||
|
export interface SupportPlan {
|
||||||
|
id: SupportPlanId;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
monthlyPrice: number;
|
||||||
|
includedHours: number;
|
||||||
|
effectiveHourlyRate: number;
|
||||||
|
recommended: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlockTimeOption {
|
||||||
|
id: BlockTimeId;
|
||||||
|
hours: number;
|
||||||
|
price: number;
|
||||||
|
effectiveHourlyRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportSelection {
|
||||||
|
planId: SupportPlanId;
|
||||||
|
useBlockTime: boolean;
|
||||||
|
blockTimeId: BlockTimeId | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VoIP Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type VoIPTierId = 'voip-basic' | 'voip-standard' | 'voip-pro' | 'voip-callcenter';
|
||||||
|
|
||||||
|
export interface VoIPTier {
|
||||||
|
id: VoIPTierId;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
pricePerUser: number;
|
||||||
|
features: string[];
|
||||||
|
recommended: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoIPHardware {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
oneTimePrice: number;
|
||||||
|
monthlyRental: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HardwareSelection {
|
||||||
|
hardwareId: string;
|
||||||
|
quantity: number;
|
||||||
|
isRental: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoIPSelection {
|
||||||
|
enabled: boolean;
|
||||||
|
tierId: VoIPTierId;
|
||||||
|
userCount: number;
|
||||||
|
hardware: HardwareSelection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Web Hosting Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type WebHostingTierId = 'hosting-starter' | 'hosting-business' | 'hosting-commerce';
|
||||||
|
|
||||||
|
export interface WebHostingTier {
|
||||||
|
id: WebHostingTierId;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
monthlyPrice: number;
|
||||||
|
storage: string;
|
||||||
|
sites: number; // -1 = unlimited
|
||||||
|
features: string[];
|
||||||
|
recommended: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebHostingSelection {
|
||||||
|
enabled: boolean;
|
||||||
|
tierId: WebHostingTierId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Email Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type EmailProvider = 'whm' | 'm365';
|
||||||
|
export type EmailTierId = 'whm-basic' | 'whm-standard' | 'whm-pro' | 'm365-basic' | 'm365-standard' | 'm365-premium';
|
||||||
|
|
||||||
|
export interface EmailTier {
|
||||||
|
id: EmailTierId;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
pricePerMailbox: number;
|
||||||
|
provider: EmailProvider;
|
||||||
|
storage: string;
|
||||||
|
features: string[];
|
||||||
|
recommended: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailSelection {
|
||||||
|
enabled: boolean;
|
||||||
|
provider: EmailProvider;
|
||||||
|
tierId: EmailTierId;
|
||||||
|
mailboxCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Company & Contact Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type Industry =
|
||||||
|
| 'Healthcare'
|
||||||
|
| 'Legal'
|
||||||
|
| 'Finance'
|
||||||
|
| 'Manufacturing'
|
||||||
|
| 'Retail'
|
||||||
|
| 'Professional Services'
|
||||||
|
| 'Other';
|
||||||
|
|
||||||
|
export type ContactPreference = 'email' | 'phone' | 'either';
|
||||||
|
|
||||||
|
export interface CompanyInfo {
|
||||||
|
name: string;
|
||||||
|
endpointCount: number;
|
||||||
|
industry: Industry | '';
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactInfo {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
companyName: string;
|
||||||
|
currentITSituation: string;
|
||||||
|
contactPreference: ContactPreference;
|
||||||
|
agreedToTerms: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Quote Data & Result Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface QuoteData {
|
||||||
|
company: CompanyInfo;
|
||||||
|
gps: GPSSelection;
|
||||||
|
support: SupportSelection;
|
||||||
|
voip: VoIPSelection;
|
||||||
|
webHosting: WebHostingSelection;
|
||||||
|
email: EmailSelection;
|
||||||
|
contact: ContactInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoteBreakdown {
|
||||||
|
gps: {
|
||||||
|
monitoring: number;
|
||||||
|
equipment: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
support: {
|
||||||
|
plan: number;
|
||||||
|
blockTime: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
voip: {
|
||||||
|
service: number;
|
||||||
|
hardware: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
webHosting: number;
|
||||||
|
email: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoteResult {
|
||||||
|
monthlyTotal: number;
|
||||||
|
oneTimeTotal: number;
|
||||||
|
breakdown: QuoteBreakdown;
|
||||||
|
gpsMonthly: number;
|
||||||
|
supportMonthly: number;
|
||||||
|
voipMonthly: number;
|
||||||
|
webHostingMonthly: number;
|
||||||
|
emailMonthly: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Wizard Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface WizardStep {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
isComplete: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepValidation {
|
||||||
|
isValid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Legacy Types (for backward compatibility)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ServiceTier = 'essential' | 'professional' | 'enterprise';
|
||||||
|
|
||||||
|
export interface DeviceCount {
|
||||||
|
workstations: number;
|
||||||
|
laptops: number;
|
||||||
|
servers: number;
|
||||||
|
networkDevices: number;
|
||||||
|
mobileDevices: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceSelection {
|
||||||
|
tier: ServiceTier;
|
||||||
|
addOns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PricingTier {
|
||||||
|
id: ServiceTier;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
basePrice: number;
|
||||||
|
perDevicePrice: number;
|
||||||
|
features: string[];
|
||||||
|
recommended?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddOn {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: number;
|
||||||
|
priceType: 'flat' | 'per-device' | 'per-user';
|
||||||
|
}
|
||||||
33
projects/msp-tools/quote-wizard/frontend/tailwind.config.js
Normal file
33
projects/msp-tools/quote-wizard/frontend/tailwind.config.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: '#333d49',
|
||||||
|
accent: '#fe7400',
|
||||||
|
navy: '#113559',
|
||||||
|
gray: {
|
||||||
|
DEFAULT: '#4d4d4d',
|
||||||
|
50: '#f9fafb',
|
||||||
|
100: '#f3f4f6',
|
||||||
|
200: '#e5e7eb',
|
||||||
|
300: '#d1d5db',
|
||||||
|
400: '#9ca3af',
|
||||||
|
500: '#6b7280',
|
||||||
|
600: '#4d4d4d',
|
||||||
|
700: '#374151',
|
||||||
|
800: '#1f2937',
|
||||||
|
900: '#111827',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
lexend: ['Lexend', 'sans-serif'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
34
projects/msp-tools/quote-wizard/frontend/tsconfig.app.json
Normal file
34
projects/msp-tools/quote-wizard/frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Path aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
projects/msp-tools/quote-wizard/frontend/tsconfig.json
Normal file
7
projects/msp-tools/quote-wizard/frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
projects/msp-tools/quote-wizard/frontend/tsconfig.node.json
Normal file
26
projects/msp-tools/quote-wizard/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
18
projects/msp-tools/quote-wizard/frontend/vite.config.ts
Normal file
18
projects/msp-tools/quote-wizard/frontend/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
10
projects/msp-tools/quote-wizard/prompts/.spec_status.json
Normal file
10
projects/msp-tools/quote-wizard/prompts/.spec_status.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"status": "complete",
|
||||||
|
"version": 1,
|
||||||
|
"timestamp": "2026-03-09T12:00:00.000Z",
|
||||||
|
"files_written": [
|
||||||
|
"prompts/app_spec.txt",
|
||||||
|
"prompts/initializer_prompt.md"
|
||||||
|
],
|
||||||
|
"feature_count": 141
|
||||||
|
}
|
||||||
533
projects/msp-tools/quote-wizard/prompts/app_spec.txt
Normal file
533
projects/msp-tools/quote-wizard/prompts/app_spec.txt
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
<project_specification>
|
||||||
|
<project_name>MSP Quote Wizard</project_name>
|
||||||
|
|
||||||
|
<overview>
|
||||||
|
An interactive quotation wizard embedded on azcomputerguru.com that guides prospects through MSP service selection, generates proposals with pricing, and syncs leads to SyncroRMM. Features a 7-step linear wizard with expandable educational content, real-time price calculations, and admin dashboard for lead management.
|
||||||
|
</overview>
|
||||||
|
|
||||||
|
<technology_stack>
|
||||||
|
<frontend>
|
||||||
|
<framework>React 19 + TypeScript</framework>
|
||||||
|
<build_tool>Vite</build_tool>
|
||||||
|
<styling>Tailwind CSS v4 (GuruRMM glassmorphism design system)</styling>
|
||||||
|
<state_management>React Context + useReducer</state_management>
|
||||||
|
<api_client>Axios + React Query</api_client>
|
||||||
|
<animations>Framer Motion</animations>
|
||||||
|
<icons>Lucide React</icons>
|
||||||
|
</frontend>
|
||||||
|
<backend>
|
||||||
|
<runtime>Python 3.11+ (FastAPI)</runtime>
|
||||||
|
<database>MariaDB 10.6</database>
|
||||||
|
<orm>SQLAlchemy</orm>
|
||||||
|
<api_host>172.16.3.30:8001 (extend existing ClaudeTools API)</api_host>
|
||||||
|
</backend>
|
||||||
|
<communication>
|
||||||
|
<api>RESTful JSON API</api>
|
||||||
|
<auth>JWT for admin endpoints, token-based for public quote access</auth>
|
||||||
|
</communication>
|
||||||
|
<integrations>
|
||||||
|
<crm>SyncroRMM API (https://computerguru.syncromsp.com/api/v1)</crm>
|
||||||
|
<email>SMTP or SendGrid for notifications</email>
|
||||||
|
<pdf>WeasyPrint or Puppeteer for quote generation</pdf>
|
||||||
|
</integrations>
|
||||||
|
</technology_stack>
|
||||||
|
|
||||||
|
<prerequisites>
|
||||||
|
<environment_setup>
|
||||||
|
- Node.js 20+ for frontend development
|
||||||
|
- Python 3.11+ for backend
|
||||||
|
- Access to MariaDB at 172.16.3.30:3306
|
||||||
|
- SyncroRMM API credentials
|
||||||
|
- SMTP credentials for email notifications
|
||||||
|
</environment_setup>
|
||||||
|
</prerequisites>
|
||||||
|
|
||||||
|
<feature_count>141</feature_count>
|
||||||
|
|
||||||
|
<security_and_access_control>
|
||||||
|
<user_roles>
|
||||||
|
<role name="public">
|
||||||
|
<permissions>
|
||||||
|
- Can create and view their own quotes via access token
|
||||||
|
- Can submit quotes with contact information
|
||||||
|
- Can view/download PDF of their quote
|
||||||
|
- Cannot access admin endpoints
|
||||||
|
</permissions>
|
||||||
|
<protected_routes>
|
||||||
|
- /api/quotes/* (public with token)
|
||||||
|
</protected_routes>
|
||||||
|
</role>
|
||||||
|
<role name="admin">
|
||||||
|
<permissions>
|
||||||
|
- Can view all quotes
|
||||||
|
- Can update quote status
|
||||||
|
- Can view analytics/stats
|
||||||
|
- Can manually sync to SyncroRMM
|
||||||
|
- Can configure notification settings
|
||||||
|
</permissions>
|
||||||
|
<protected_routes>
|
||||||
|
- /api/admin/* (JWT required)
|
||||||
|
- /admin/* pages in GuruRMM dashboard
|
||||||
|
</protected_routes>
|
||||||
|
</role>
|
||||||
|
</user_roles>
|
||||||
|
<authentication>
|
||||||
|
<method>Token-based for public quotes, JWT for admin</method>
|
||||||
|
<session_timeout>24 hours for quote tokens, standard JWT for admin</session_timeout>
|
||||||
|
<quote_expiry>30 days after creation</quote_expiry>
|
||||||
|
</authentication>
|
||||||
|
<sensitive_operations>
|
||||||
|
- Quote submission triggers SyncroRMM sync
|
||||||
|
- Admin status changes are logged
|
||||||
|
- Email/phone validation before sync
|
||||||
|
</sensitive_operations>
|
||||||
|
<seo>
|
||||||
|
- noindex, nofollow meta tags on quote wizard
|
||||||
|
- X-Robots-Tag header on hosting server
|
||||||
|
</seo>
|
||||||
|
</security_and_access_control>
|
||||||
|
|
||||||
|
<core_features>
|
||||||
|
<wizard_navigation>
|
||||||
|
- Progress bar showing current step and completion
|
||||||
|
- Step indicators with clickable navigation (for completed steps)
|
||||||
|
- Next/Back buttons with validation
|
||||||
|
- Step transition animations
|
||||||
|
- Auto-save draft on step change
|
||||||
|
- Resume incomplete quote via token
|
||||||
|
- Mobile-responsive step layout
|
||||||
|
</wizard_navigation>
|
||||||
|
|
||||||
|
<step_1_company_profile>
|
||||||
|
- Company name input (optional)
|
||||||
|
- Number of endpoints/employees input
|
||||||
|
- Industry dropdown selection
|
||||||
|
- "What brings you here today?" textarea (optional)
|
||||||
|
- Form validation with helpful messages
|
||||||
|
- Auto-create quote draft on entry
|
||||||
|
</step_1_company_profile>
|
||||||
|
|
||||||
|
<step_2_gps_monitoring>
|
||||||
|
- Three tier pricing cards (Basic $19, Pro $26, Advanced $39)
|
||||||
|
- Expandable feature descriptions for each tier
|
||||||
|
- Quantity input tied to endpoint count from Step 1
|
||||||
|
- Equipment monitoring add-on toggle ($25/mo base + $3/device)
|
||||||
|
- Real-time price calculation display
|
||||||
|
- Tier comparison table (expandable)
|
||||||
|
- Recommended tier highlight based on company size
|
||||||
|
</step_2_gps_monitoring>
|
||||||
|
|
||||||
|
<step_3_support_plan>
|
||||||
|
- Four tier pricing cards (Essential $200, Standard $380, Premium $540, Priority $850)
|
||||||
|
- Included hours and response time display
|
||||||
|
- Effective hourly rate calculation
|
||||||
|
- Prepaid block time option (10hr/$1500, 20hr/$2600, 30hr/$3000)
|
||||||
|
- Expandable details for each tier
|
||||||
|
- Recommendation based on endpoint count
|
||||||
|
</step_3_support_plan>
|
||||||
|
|
||||||
|
<step_4_voip>
|
||||||
|
- Toggle: "Need business phones?"
|
||||||
|
- Skip step if toggle is off
|
||||||
|
- Four VoIP tier cards (Basic $22, Standard $28, Pro $35, CallCenter $55)
|
||||||
|
- User count input
|
||||||
|
- Hardware options with quantity selectors
|
||||||
|
- Basic Desk Phone (T53W) $219
|
||||||
|
- Business Desk Phone (T54W) $279
|
||||||
|
- Executive Phone (T57W) $359
|
||||||
|
- Conference Phone (CP920) $599
|
||||||
|
- Wireless Headset (WH62) $159
|
||||||
|
- Cordless Phone (W73P) $199
|
||||||
|
- Add-on services (DID, toll-free, SMS, fax, Teams)
|
||||||
|
- Real-time total calculation
|
||||||
|
</step_4_voip>
|
||||||
|
|
||||||
|
<step_5_web_email>
|
||||||
|
- Web hosting toggle with tier selection
|
||||||
|
- Starter $15 (5GB, 1 site)
|
||||||
|
- Business $35 (25GB, 5 sites)
|
||||||
|
- Commerce $65 (50GB, unlimited)
|
||||||
|
- Email provider choice (expandable comparison)
|
||||||
|
- WHM Email ($2-20/mailbox based on storage)
|
||||||
|
- Microsoft 365 Basic $7, Standard $14, Premium $24
|
||||||
|
- Exchange Online $5
|
||||||
|
- Email user count input
|
||||||
|
- Add-ons: email security $3/mailbox, dedicated IP $5, SSL $6.25
|
||||||
|
</step_5_web_email>
|
||||||
|
|
||||||
|
<step_6_summary>
|
||||||
|
- Itemized breakdown by category
|
||||||
|
- Monthly recurring total (prominent display)
|
||||||
|
- One-time/setup costs (separate section)
|
||||||
|
- Edit buttons to revisit any step
|
||||||
|
- Collapsible category sections
|
||||||
|
- Savings highlight if applicable
|
||||||
|
- Print-friendly view option
|
||||||
|
</step_6_summary>
|
||||||
|
|
||||||
|
<step_7_contact>
|
||||||
|
- Contact name (required)
|
||||||
|
- Email address (required, validated)
|
||||||
|
- Phone number (recommended, formatted)
|
||||||
|
- Company name (pre-filled from Step 1)
|
||||||
|
- Current IT situation textarea
|
||||||
|
- Preferred contact method selection
|
||||||
|
- Terms acceptance checkbox
|
||||||
|
- Submit button with loading state
|
||||||
|
- Duplicate email check against SyncroRMM
|
||||||
|
- Success confirmation with quote reference
|
||||||
|
</step_7_contact>
|
||||||
|
|
||||||
|
<expandable_info>
|
||||||
|
- Collapsible info cards throughout wizard
|
||||||
|
- "Learn more" buttons for each feature
|
||||||
|
- Smooth expand/collapse animations
|
||||||
|
- Feature definitions in plain language
|
||||||
|
- Use case examples
|
||||||
|
- Comparison tables within expandables
|
||||||
|
</expandable_info>
|
||||||
|
|
||||||
|
<pricing_calculations>
|
||||||
|
- Real-time total updates as selections change
|
||||||
|
- Category subtotals
|
||||||
|
- One-time vs recurring separation
|
||||||
|
- Quantity-based calculations
|
||||||
|
- Add-on aggregation
|
||||||
|
- Discount display (if applicable)
|
||||||
|
</pricing_calculations>
|
||||||
|
|
||||||
|
<quote_api_public>
|
||||||
|
- POST /api/quotes - Create new quote (returns access_token)
|
||||||
|
- GET /api/quotes/{token} - Get quote by access token
|
||||||
|
- PUT /api/quotes/{token} - Update quote (wizard progress)
|
||||||
|
- POST /api/quotes/{token}/submit - Finalize and submit
|
||||||
|
- GET /api/quotes/{token}/pdf - Generate PDF
|
||||||
|
- Rate limiting for public endpoints
|
||||||
|
</quote_api_public>
|
||||||
|
|
||||||
|
<quote_api_admin>
|
||||||
|
- GET /api/admin/quotes - List all quotes (paginated, filterable)
|
||||||
|
- GET /api/admin/quotes/{id} - Get quote details
|
||||||
|
- PUT /api/admin/quotes/{id} - Update status, add notes
|
||||||
|
- GET /api/admin/quotes/stats - Dashboard analytics
|
||||||
|
- POST /api/admin/quotes/{id}/sync-syncro - Manual sync
|
||||||
|
</quote_api_admin>
|
||||||
|
|
||||||
|
<syncro_integration>
|
||||||
|
- Duplicate check via GET /customers?email={email}
|
||||||
|
- Lead creation via POST /leads
|
||||||
|
- Quote details in ticket_description
|
||||||
|
- Sync status tracking
|
||||||
|
- Error handling for API failures
|
||||||
|
- Manual retry capability
|
||||||
|
</syncro_integration>
|
||||||
|
|
||||||
|
<notifications>
|
||||||
|
- Customer confirmation email with quote link
|
||||||
|
- Admin alert email on new submission
|
||||||
|
- Email templates with branding
|
||||||
|
- Quote PDF attachment option
|
||||||
|
- Webhook support for automation
|
||||||
|
</notifications>
|
||||||
|
|
||||||
|
<admin_dashboard>
|
||||||
|
- Quote listing with filters (status, date, value)
|
||||||
|
- Search by company/contact/email
|
||||||
|
- Quote detail view with full breakdown
|
||||||
|
- Activity timeline per quote
|
||||||
|
- Status management (draft, submitted, followed_up, converted)
|
||||||
|
- SyncroRMM sync status indicator
|
||||||
|
- Basic analytics (conversion funnel, popular services)
|
||||||
|
</admin_dashboard>
|
||||||
|
|
||||||
|
<pdf_generation>
|
||||||
|
- Professional quote document
|
||||||
|
- Company branding (logo, colors)
|
||||||
|
- Itemized service breakdown
|
||||||
|
- Terms and conditions
|
||||||
|
- Validity period display
|
||||||
|
- Contact information
|
||||||
|
</pdf_generation>
|
||||||
|
</core_features>
|
||||||
|
|
||||||
|
<database_schema>
|
||||||
|
<tables>
|
||||||
|
<quotes>
|
||||||
|
- id (UUID, PK)
|
||||||
|
- company_name (VARCHAR 255, nullable)
|
||||||
|
- contact_name (VARCHAR 255, not null)
|
||||||
|
- contact_email (VARCHAR 255, not null)
|
||||||
|
- contact_phone (VARCHAR 50, nullable)
|
||||||
|
- employee_count (INT, nullable)
|
||||||
|
- industry (VARCHAR 100, nullable)
|
||||||
|
- current_it_situation (TEXT, nullable)
|
||||||
|
- status (ENUM: draft, submitted, viewed, followed_up, converted, expired)
|
||||||
|
- access_token (VARCHAR 64, unique, not null)
|
||||||
|
- monthly_total (DECIMAL 10,2)
|
||||||
|
- setup_total (DECIMAL 10,2)
|
||||||
|
- syncro_lead_id (VARCHAR 100, nullable)
|
||||||
|
- syncro_synced_at (DATETIME, nullable)
|
||||||
|
- is_existing_customer (BOOLEAN, default false)
|
||||||
|
- source (VARCHAR 50, default 'website')
|
||||||
|
- utm_source, utm_medium, utm_campaign (VARCHAR 100 each)
|
||||||
|
- ip_address (VARCHAR 45)
|
||||||
|
- user_agent (TEXT)
|
||||||
|
- created_at, updated_at, submitted_at, expires_at (DATETIME)
|
||||||
|
</quotes>
|
||||||
|
|
||||||
|
<quote_items>
|
||||||
|
- id (UUID, PK)
|
||||||
|
- quote_id (UUID, FK to quotes, cascade delete)
|
||||||
|
- category (ENUM: gps_monitoring, support_plan, voip, web_hosting, email, hardware, addon)
|
||||||
|
- product_code (VARCHAR 50, not null)
|
||||||
|
- product_name (VARCHAR 255, not null)
|
||||||
|
- description (TEXT, nullable)
|
||||||
|
- quantity (INT, default 1)
|
||||||
|
- unit_price (DECIMAL 10,2, not null)
|
||||||
|
- setup_price (DECIMAL 10,2, default 0)
|
||||||
|
- billing_frequency (ENUM: monthly, yearly, one_time)
|
||||||
|
- tier (VARCHAR 50, nullable)
|
||||||
|
- is_recommended (BOOLEAN, default false)
|
||||||
|
- created_at (DATETIME)
|
||||||
|
</quote_items>
|
||||||
|
|
||||||
|
<quote_activity>
|
||||||
|
- id (UUID, PK)
|
||||||
|
- quote_id (UUID, FK to quotes, cascade delete)
|
||||||
|
- action (VARCHAR 50, not null: created, step_completed, submitted, viewed, pdf_generated, synced_syncro, status_changed)
|
||||||
|
- step_name (VARCHAR 50, nullable)
|
||||||
|
- details (JSON, nullable)
|
||||||
|
- ip_address (VARCHAR 45, nullable)
|
||||||
|
- created_at (DATETIME)
|
||||||
|
</quote_activity>
|
||||||
|
|
||||||
|
<quote_notifications>
|
||||||
|
- id (UUID, PK)
|
||||||
|
- quote_id (UUID, FK to quotes, cascade delete)
|
||||||
|
- notification_type (ENUM: email, webhook)
|
||||||
|
- recipient (VARCHAR 255, not null)
|
||||||
|
- subject (VARCHAR 255, nullable)
|
||||||
|
- body (TEXT, nullable)
|
||||||
|
- status (ENUM: pending, sent, failed)
|
||||||
|
- attempts (INT, default 0)
|
||||||
|
- last_attempt_at, sent_at (DATETIME, nullable)
|
||||||
|
- error_message (TEXT, nullable)
|
||||||
|
- created_at (DATETIME)
|
||||||
|
</quote_notifications>
|
||||||
|
</tables>
|
||||||
|
</database_schema>
|
||||||
|
|
||||||
|
<api_endpoints_summary>
|
||||||
|
<public_quotes>
|
||||||
|
- POST /api/quotes (create quote, returns token)
|
||||||
|
- GET /api/quotes/{token} (get quote by token)
|
||||||
|
- PUT /api/quotes/{token} (update quote)
|
||||||
|
- POST /api/quotes/{token}/submit (finalize)
|
||||||
|
- GET /api/quotes/{token}/pdf (generate PDF)
|
||||||
|
</public_quotes>
|
||||||
|
|
||||||
|
<admin_quotes>
|
||||||
|
- GET /api/admin/quotes (list with filters)
|
||||||
|
- GET /api/admin/quotes/{id} (detail view)
|
||||||
|
- PUT /api/admin/quotes/{id} (update status/notes)
|
||||||
|
- GET /api/admin/quotes/stats (analytics)
|
||||||
|
- POST /api/admin/quotes/{id}/sync-syncro (manual sync)
|
||||||
|
</admin_quotes>
|
||||||
|
|
||||||
|
<syncro_proxy>
|
||||||
|
- GET /api/syncro/check-customer?email={email} (duplicate check)
|
||||||
|
</syncro_proxy>
|
||||||
|
</api_endpoints_summary>
|
||||||
|
|
||||||
|
<ui_layout>
|
||||||
|
<main_structure>
|
||||||
|
Full-width wizard container with centered content (max-width 1200px).
|
||||||
|
Progress bar at top showing 7 steps.
|
||||||
|
Main content area with current step.
|
||||||
|
Fixed bottom navigation (Back/Next buttons).
|
||||||
|
Running total display in corner/sidebar on desktop.
|
||||||
|
</main_structure>
|
||||||
|
|
||||||
|
<wizard_step_layout>
|
||||||
|
Step title with icon.
|
||||||
|
Optional subtitle/description.
|
||||||
|
Main content area (cards, forms, selections).
|
||||||
|
Expandable info sections.
|
||||||
|
Step-specific help text.
|
||||||
|
</wizard_step_layout>
|
||||||
|
|
||||||
|
<pricing_card_layout>
|
||||||
|
Card with tier name and price header.
|
||||||
|
Feature list with checkmarks.
|
||||||
|
"Most Popular" badge for recommended tier.
|
||||||
|
Select button at bottom.
|
||||||
|
Expandable "Learn more" section.
|
||||||
|
</pricing_card_layout>
|
||||||
|
|
||||||
|
<admin_layout>
|
||||||
|
Integrated into existing GuruRMM dashboard.
|
||||||
|
Left sidebar navigation (add "Quotes" menu item).
|
||||||
|
Main content area with quote listing.
|
||||||
|
Slide-out panel for quick view.
|
||||||
|
Full page for quote details.
|
||||||
|
</admin_layout>
|
||||||
|
</ui_layout>
|
||||||
|
|
||||||
|
<design_system>
|
||||||
|
<color_palette>
|
||||||
|
Match azcomputerguru.com website theme:
|
||||||
|
- Primary Dark: #333d49 (dark blue-gray)
|
||||||
|
- Accent Orange: #fe7400 (call-to-action, highlights)
|
||||||
|
- Navy: #113559 (headers, dark elements)
|
||||||
|
- White: #ffffff (backgrounds, text on dark)
|
||||||
|
- Black: #000000 (text)
|
||||||
|
- Gray: #4d4d4d (secondary text)
|
||||||
|
</color_palette>
|
||||||
|
|
||||||
|
<typography>
|
||||||
|
- Font Family: Lexend (Google Fonts) - same as main website
|
||||||
|
- Headings: Bold weight, navy or dark
|
||||||
|
- Body: Regular weight, gray/black
|
||||||
|
- Prices: Bold, larger size, orange accent (#fe7400)
|
||||||
|
</typography>
|
||||||
|
|
||||||
|
<effects>
|
||||||
|
- Clean, professional cards with subtle shadows
|
||||||
|
- Smooth transitions (200ms)
|
||||||
|
- Orange hover effects on buttons
|
||||||
|
- Progress bar with orange fill
|
||||||
|
- Step transition slides
|
||||||
|
- Consistent with main website aesthetic
|
||||||
|
</effects>
|
||||||
|
</design_system>
|
||||||
|
|
||||||
|
<implementation_steps>
|
||||||
|
<step number="1">
|
||||||
|
<title>Foundation - Database and API Setup</title>
|
||||||
|
<tasks>
|
||||||
|
- Create database migration for quote tables
|
||||||
|
- Build SQLAlchemy models (Quote, QuoteItem, QuoteActivity, QuoteNotification)
|
||||||
|
- Create Pydantic schemas for request/response
|
||||||
|
- Implement QuoteService with CRUD operations
|
||||||
|
- Build public quote endpoints (/api/quotes/*)
|
||||||
|
- Add token generation and validation
|
||||||
|
</tasks>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step number="2">
|
||||||
|
<title>Frontend Project Setup</title>
|
||||||
|
<tasks>
|
||||||
|
- Initialize Vite + React + TypeScript project
|
||||||
|
- Configure Tailwind CSS v4 with GuruRMM design tokens
|
||||||
|
- Copy/adapt UI components from GuruRMM (Button, Card, Input)
|
||||||
|
- Set up React Router for wizard navigation
|
||||||
|
- Configure Axios + React Query for API calls
|
||||||
|
- Create pricing data constants from MSP pricing docs
|
||||||
|
</tasks>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step number="3">
|
||||||
|
<title>Wizard Core Implementation</title>
|
||||||
|
<tasks>
|
||||||
|
- Build WizardContainer with progress tracking
|
||||||
|
- Implement WizardProgress component
|
||||||
|
- Create each step component (Steps 1-7)
|
||||||
|
- Build pricing card components
|
||||||
|
- Implement quantity selectors and toggles
|
||||||
|
- Wire up quote creation/update API calls
|
||||||
|
- Add form validation for each step
|
||||||
|
</tasks>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step number="4">
|
||||||
|
<title>Educational Content and Polish</title>
|
||||||
|
<tasks>
|
||||||
|
- Build ExpandableInfo component
|
||||||
|
- Add feature descriptions and comparisons
|
||||||
|
- Implement tier comparison tables
|
||||||
|
- Add Framer Motion animations
|
||||||
|
- Ensure mobile responsiveness
|
||||||
|
- Add loading states and error handling
|
||||||
|
</tasks>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step number="5">
|
||||||
|
<title>Integrations</title>
|
||||||
|
<tasks>
|
||||||
|
- Build SyncroService for API integration
|
||||||
|
- Implement duplicate customer check
|
||||||
|
- Create lead in SyncroRMM on submit
|
||||||
|
- Build NotificationService for emails
|
||||||
|
- Create email templates
|
||||||
|
- Implement PDF generation
|
||||||
|
</tasks>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step number="6">
|
||||||
|
<title>Admin Dashboard</title>
|
||||||
|
<tasks>
|
||||||
|
- Add admin API endpoints
|
||||||
|
- Build quote listing page in GuruRMM
|
||||||
|
- Create quote detail view
|
||||||
|
- Implement filters and search
|
||||||
|
- Add status management
|
||||||
|
- Build basic analytics view
|
||||||
|
</tasks>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step number="7">
|
||||||
|
<title>Deployment and Website Link</title>
|
||||||
|
<tasks>
|
||||||
|
- Build production frontend bundle
|
||||||
|
- Deploy to quote.azcomputerguru.com or ClaudeTools server
|
||||||
|
- Add noindex meta tags to quote wizard
|
||||||
|
- Configure CORS for API access
|
||||||
|
- Add "Get a Quote" button/link on azcomputerguru.com
|
||||||
|
- End-to-end testing
|
||||||
|
</tasks>
|
||||||
|
</step>
|
||||||
|
</implementation_steps>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
<functionality>
|
||||||
|
- Complete wizard flow from start to submission
|
||||||
|
- All pricing calculations accurate
|
||||||
|
- Quote saved to database with all items
|
||||||
|
- SyncroRMM lead created on submission
|
||||||
|
- Email notifications sent
|
||||||
|
- PDF generation works
|
||||||
|
- Admin can view and manage all quotes
|
||||||
|
</functionality>
|
||||||
|
|
||||||
|
<user_experience>
|
||||||
|
- Wizard intuitive for non-technical users
|
||||||
|
- Expandable info provides education without cluttering
|
||||||
|
- Progress clearly visible at all times
|
||||||
|
- Mobile-friendly on all devices
|
||||||
|
- Fast loading and responsive interactions
|
||||||
|
</user_experience>
|
||||||
|
|
||||||
|
<technical_quality>
|
||||||
|
- No mock data - all real database operations
|
||||||
|
- Proper error handling throughout
|
||||||
|
- API validation on both client and server
|
||||||
|
- Secure token-based quote access
|
||||||
|
- Rate limiting on public endpoints
|
||||||
|
</technical_quality>
|
||||||
|
|
||||||
|
<design_polish>
|
||||||
|
- Matches GuruRMM design system
|
||||||
|
- Consistent glassmorphism styling
|
||||||
|
- Smooth animations and transitions
|
||||||
|
- Professional appearance suitable for business
|
||||||
|
</design_polish>
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<pricing_data_reference>
|
||||||
|
<source_files>
|
||||||
|
- /projects/msp-pricing/docs/gps-pricing-structure.md
|
||||||
|
- /projects/msp-pricing/docs/voip-pricing-structure.md
|
||||||
|
- /projects/msp-pricing/docs/web-email-hosting-pricing.md
|
||||||
|
</source_files>
|
||||||
|
</pricing_data_reference>
|
||||||
|
</project_specification>
|
||||||
523
projects/msp-tools/quote-wizard/prompts/initializer_prompt.md
Normal file
523
projects/msp-tools/quote-wizard/prompts/initializer_prompt.md
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
## YOUR ROLE - INITIALIZER AGENT (Session 1 of Many)
|
||||||
|
|
||||||
|
You are the FIRST agent in a long-running autonomous development process.
|
||||||
|
Your job is to set up the foundation for all future coding agents.
|
||||||
|
|
||||||
|
### FIRST: Read the Project Specification
|
||||||
|
|
||||||
|
Start by reading `app_spec.txt` in your working directory. This file contains
|
||||||
|
the complete specification for what you need to build. Read it carefully
|
||||||
|
before proceeding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## REQUIRED FEATURE COUNT
|
||||||
|
|
||||||
|
**CRITICAL:** You must create exactly **141** features using the `feature_create_bulk` tool.
|
||||||
|
|
||||||
|
This number was determined during spec creation and must be followed precisely. Do not create more or fewer features than specified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CRITICAL FIRST TASK: Create Features
|
||||||
|
|
||||||
|
Based on `app_spec.txt`, create features using the feature_create_bulk tool. The features are stored in a SQLite database,
|
||||||
|
which is the single source of truth for what needs to be built.
|
||||||
|
|
||||||
|
**Creating Features:**
|
||||||
|
|
||||||
|
Use the feature_create_bulk tool to add all features at once:
|
||||||
|
|
||||||
|
```
|
||||||
|
Use the feature_create_bulk tool with features=[
|
||||||
|
{
|
||||||
|
"category": "functional",
|
||||||
|
"name": "Brief feature name",
|
||||||
|
"description": "Brief description of the feature and what this test verifies",
|
||||||
|
"steps": [
|
||||||
|
"Step 1: Navigate to relevant page",
|
||||||
|
"Step 2: Perform action",
|
||||||
|
"Step 3: Verify expected result"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "style",
|
||||||
|
"name": "Brief feature name",
|
||||||
|
"description": "Brief description of UI/UX requirement",
|
||||||
|
"steps": [
|
||||||
|
"Step 1: Navigate to page",
|
||||||
|
"Step 2: Take screenshot",
|
||||||
|
"Step 3: Verify visual requirements"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- IDs and priorities are assigned automatically based on order
|
||||||
|
- All features start with `passes: false` by default
|
||||||
|
- You can create features in batches if there are many (e.g., 50 at a time)
|
||||||
|
|
||||||
|
**Requirements for features:**
|
||||||
|
|
||||||
|
- Feature count must match the `feature_count` specified in app_spec.txt
|
||||||
|
- Reference tiers for other projects:
|
||||||
|
- **Simple apps**: ~150 tests
|
||||||
|
- **Medium apps**: ~250 tests
|
||||||
|
- **Complex apps**: ~400+ tests
|
||||||
|
- Both "functional" and "style" categories
|
||||||
|
- Mix of narrow tests (2-5 steps) and comprehensive tests (10+ steps)
|
||||||
|
- At least 25 tests MUST have 10+ steps each (more for complex apps)
|
||||||
|
- Order features by priority: fundamental features first (the API assigns priority based on order)
|
||||||
|
- All features start with `passes: false` automatically
|
||||||
|
- Cover every feature in the spec exhaustively
|
||||||
|
- **MUST include tests from ALL 20 mandatory categories below**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MANDATORY TEST CATEGORIES
|
||||||
|
|
||||||
|
The feature_list.json **MUST** include tests from ALL of these categories. The minimum counts scale by complexity tier.
|
||||||
|
|
||||||
|
### Category Distribution by Complexity Tier
|
||||||
|
|
||||||
|
| Category | Simple | Medium | Complex |
|
||||||
|
| -------------------------------- | ------- | ------- | -------- |
|
||||||
|
| A. Security & Access Control | 5 | 20 | 40 |
|
||||||
|
| B. Navigation Integrity | 15 | 25 | 40 |
|
||||||
|
| C. Real Data Verification | 20 | 30 | 50 |
|
||||||
|
| D. Workflow Completeness | 10 | 20 | 40 |
|
||||||
|
| E. Error Handling | 10 | 15 | 25 |
|
||||||
|
| F. UI-Backend Integration | 10 | 20 | 35 |
|
||||||
|
| G. State & Persistence | 8 | 10 | 15 |
|
||||||
|
| H. URL & Direct Access | 5 | 10 | 20 |
|
||||||
|
| I. Double-Action & Idempotency | 5 | 8 | 15 |
|
||||||
|
| J. Data Cleanup & Cascade | 5 | 10 | 20 |
|
||||||
|
| K. Default & Reset | 5 | 8 | 12 |
|
||||||
|
| L. Search & Filter Edge Cases | 8 | 12 | 20 |
|
||||||
|
| M. Form Validation | 10 | 15 | 25 |
|
||||||
|
| N. Feedback & Notification | 8 | 10 | 15 |
|
||||||
|
| O. Responsive & Layout | 8 | 10 | 15 |
|
||||||
|
| P. Accessibility | 8 | 10 | 15 |
|
||||||
|
| Q. Temporal & Timezone | 5 | 8 | 12 |
|
||||||
|
| R. Concurrency & Race Conditions | 5 | 8 | 15 |
|
||||||
|
| S. Export/Import | 5 | 6 | 10 |
|
||||||
|
| T. Performance | 5 | 5 | 10 |
|
||||||
|
| **TOTAL** | **150** | **250** | **400+** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A. Security & Access Control Tests
|
||||||
|
|
||||||
|
Test that unauthorized access is blocked and permissions are enforced.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- Unauthenticated user cannot access protected routes (redirect to login)
|
||||||
|
- Regular user cannot access admin-only pages (403 or redirect)
|
||||||
|
- API endpoints return 401 for unauthenticated requests
|
||||||
|
- API endpoints return 403 for unauthorized role access
|
||||||
|
- Session expires after configured inactivity period
|
||||||
|
- Logout clears all session data and tokens
|
||||||
|
- Invalid/expired tokens are rejected
|
||||||
|
- Each role can ONLY see their permitted menu items
|
||||||
|
- Direct URL access to unauthorized pages is blocked
|
||||||
|
- Sensitive operations require confirmation or re-authentication
|
||||||
|
- Cannot access another user's data by manipulating IDs in URL
|
||||||
|
- Password reset flow works securely
|
||||||
|
- Failed login attempts are handled (no information leakage)
|
||||||
|
|
||||||
|
### B. Navigation Integrity Tests
|
||||||
|
|
||||||
|
Test that every button, link, and menu item goes to the correct place.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- Every button in sidebar navigates to correct page
|
||||||
|
- Every menu item links to existing route
|
||||||
|
- All CRUD action buttons (Edit, Delete, View) go to correct URLs with correct IDs
|
||||||
|
- Back button works correctly after each navigation
|
||||||
|
- Deep linking works (direct URL access to any page with auth)
|
||||||
|
- Breadcrumbs reflect actual navigation path
|
||||||
|
- 404 page shown for non-existent routes (not crash)
|
||||||
|
- After login, user redirected to intended destination (or dashboard)
|
||||||
|
- After logout, user redirected to login page
|
||||||
|
- Pagination links work and preserve current filters
|
||||||
|
- Tab navigation within pages works correctly
|
||||||
|
- Modal close buttons return to previous state
|
||||||
|
- Cancel buttons on forms return to previous page
|
||||||
|
|
||||||
|
### C. Real Data Verification Tests
|
||||||
|
|
||||||
|
Test that data is real (not mocked) and persists correctly.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- Create a record via UI with unique content → verify it appears in list
|
||||||
|
- Create a record → refresh page → record still exists
|
||||||
|
- Create a record → log out → log in → record still exists
|
||||||
|
- Edit a record → verify changes persist after refresh
|
||||||
|
- Delete a record → verify it's gone from list AND database
|
||||||
|
- Delete a record → verify it's gone from related dropdowns
|
||||||
|
- Filter/search → results match actual data created in test
|
||||||
|
- Dashboard statistics reflect real record counts (create 3 items, count shows 3)
|
||||||
|
- Reports show real aggregated data
|
||||||
|
- Export functionality exports actual data you created
|
||||||
|
- Related records update when parent changes
|
||||||
|
- Timestamps are real and accurate (created_at, updated_at)
|
||||||
|
- Data created by User A is not visible to User B (unless shared)
|
||||||
|
- Empty state shows correctly when no data exists
|
||||||
|
|
||||||
|
### D. Workflow Completeness Tests
|
||||||
|
|
||||||
|
Test that every workflow can be completed end-to-end through the UI.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- Every entity has working Create operation via UI form
|
||||||
|
- Every entity has working Read/View operation (detail page loads)
|
||||||
|
- Every entity has working Update operation (edit form saves)
|
||||||
|
- Every entity has working Delete operation (with confirmation dialog)
|
||||||
|
- Every status/state has a UI mechanism to transition to next state
|
||||||
|
- Multi-step processes (wizards) can be completed end-to-end
|
||||||
|
- Bulk operations (select all, delete selected) work
|
||||||
|
- Cancel/Undo operations work where applicable
|
||||||
|
- Required fields prevent submission when empty
|
||||||
|
- Form validation shows errors before submission
|
||||||
|
- Successful submission shows success feedback
|
||||||
|
- Backend workflow (e.g., user→customer conversion) has UI trigger
|
||||||
|
|
||||||
|
### E. Error Handling Tests
|
||||||
|
|
||||||
|
Test graceful handling of errors and edge cases.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- Network failure shows user-friendly error message, not crash
|
||||||
|
- Invalid form input shows field-level errors
|
||||||
|
- API errors display meaningful messages to user
|
||||||
|
- 404 responses handled gracefully (show not found page)
|
||||||
|
- 500 responses don't expose stack traces or technical details
|
||||||
|
- Empty search results show "no results found" message
|
||||||
|
- Loading states shown during all async operations
|
||||||
|
- Timeout doesn't hang the UI indefinitely
|
||||||
|
- Submitting form with server error keeps user data in form
|
||||||
|
- File upload errors (too large, wrong type) show clear message
|
||||||
|
- Duplicate entry errors (e.g., email already exists) are clear
|
||||||
|
|
||||||
|
### F. UI-Backend Integration Tests
|
||||||
|
|
||||||
|
Test that frontend and backend communicate correctly.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- Frontend request format matches what backend expects
|
||||||
|
- Backend response format matches what frontend parses
|
||||||
|
- All dropdown options come from real database data (not hardcoded)
|
||||||
|
- Related entity selectors (e.g., "choose category") populated from DB
|
||||||
|
- Changes in one area reflect in related areas after refresh
|
||||||
|
- Deleting parent handles children correctly (cascade or block)
|
||||||
|
- Filters work with actual data attributes from database
|
||||||
|
- Sort functionality sorts real data correctly
|
||||||
|
- Pagination returns correct page of real data
|
||||||
|
- API error responses are parsed and displayed correctly
|
||||||
|
- Loading spinners appear during API calls
|
||||||
|
- Optimistic updates (if used) rollback on failure
|
||||||
|
|
||||||
|
### G. State & Persistence Tests
|
||||||
|
|
||||||
|
Test that state is maintained correctly across sessions and tabs.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- Refresh page mid-form - appropriate behavior (data kept or cleared)
|
||||||
|
- Close browser, reopen - session state handled correctly
|
||||||
|
- Same user in two browser tabs - changes sync or handled gracefully
|
||||||
|
- Browser back after form submit - no duplicate submission
|
||||||
|
- Bookmark a page, return later - works (with auth check)
|
||||||
|
- LocalStorage/cookies cleared - graceful re-authentication
|
||||||
|
- Unsaved changes warning when navigating away from dirty form
|
||||||
|
|
||||||
|
### H. URL & Direct Access Tests
|
||||||
|
|
||||||
|
Test direct URL access and URL manipulation security.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- Change entity ID in URL - cannot access others' data
|
||||||
|
- Access /admin directly as regular user - blocked
|
||||||
|
- Malformed URL parameters - handled gracefully (no crash)
|
||||||
|
- Very long URL - handled correctly
|
||||||
|
- URL with SQL injection attempt - rejected/sanitized
|
||||||
|
- Deep link to deleted entity - shows "not found", not crash
|
||||||
|
- Query parameters for filters are reflected in UI
|
||||||
|
- Sharing a URL with filters preserves those filters
|
||||||
|
|
||||||
|
### I. Double-Action & Idempotency Tests
|
||||||
|
|
||||||
|
Test that rapid or duplicate actions don't cause issues.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- Double-click submit button - only one record created
|
||||||
|
- Rapid multiple clicks on delete - only one deletion occurs
|
||||||
|
- Submit form, hit back, submit again - appropriate behavior
|
||||||
|
- Multiple simultaneous API calls - server handles correctly
|
||||||
|
- Refresh during save operation - data not corrupted
|
||||||
|
- Click same navigation link twice quickly - no issues
|
||||||
|
- Submit button disabled during processing
|
||||||
|
|
||||||
|
### J. Data Cleanup & Cascade Tests
|
||||||
|
|
||||||
|
Test that deleting data cleans up properly everywhere.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- Delete parent entity - children removed from all views
|
||||||
|
- Delete item - removed from search results immediately
|
||||||
|
- Delete item - statistics/counts updated immediately
|
||||||
|
- Delete item - related dropdowns updated
|
||||||
|
- Delete item - cached views refreshed
|
||||||
|
- Soft delete (if applicable) - item hidden but recoverable
|
||||||
|
- Hard delete - item completely removed from database
|
||||||
|
|
||||||
|
### K. Default & Reset Tests
|
||||||
|
|
||||||
|
Test that defaults and reset functionality work correctly.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- New form shows correct default values
|
||||||
|
- Date pickers default to sensible dates (today, not 1970)
|
||||||
|
- Dropdowns default to correct option (or placeholder)
|
||||||
|
- Reset button clears to defaults, not just empty
|
||||||
|
- Clear filters button resets all filters to default
|
||||||
|
- Pagination resets to page 1 when filters change
|
||||||
|
- Sorting resets when changing views
|
||||||
|
|
||||||
|
### L. Search & Filter Edge Cases
|
||||||
|
|
||||||
|
Test search and filter functionality thoroughly.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- Empty search shows all results (or appropriate message)
|
||||||
|
- Search with only spaces - handled correctly
|
||||||
|
- Search with special characters (!@#$%^&\*) - no errors
|
||||||
|
- Search with quotes - handled correctly
|
||||||
|
- Search with very long string - handled correctly
|
||||||
|
- Filter combinations that return zero results - shows message
|
||||||
|
- Filter + search + sort together - all work correctly
|
||||||
|
- Filter persists after viewing detail and returning to list
|
||||||
|
- Clear individual filter - works correctly
|
||||||
|
- Search is case-insensitive (or clearly case-sensitive)
|
||||||
|
|
||||||
|
### M. Form Validation Tests
|
||||||
|
|
||||||
|
Test all form validation rules exhaustively.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- Required field empty - shows error, blocks submit
|
||||||
|
- Email field with invalid email formats - shows error
|
||||||
|
- Password field - enforces complexity requirements
|
||||||
|
- Numeric field with letters - rejected
|
||||||
|
- Date field with invalid date - rejected
|
||||||
|
- Min/max length enforced on text fields
|
||||||
|
- Min/max values enforced on numeric fields
|
||||||
|
- Duplicate unique values rejected (e.g., duplicate email)
|
||||||
|
- Error messages are specific (not just "invalid")
|
||||||
|
- Errors clear when user fixes the issue
|
||||||
|
- Server-side validation matches client-side
|
||||||
|
- Whitespace-only input rejected for required fields
|
||||||
|
|
||||||
|
### N. Feedback & Notification Tests
|
||||||
|
|
||||||
|
Test that users get appropriate feedback for all actions.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- Every successful save/create shows success feedback
|
||||||
|
- Every failed action shows error feedback
|
||||||
|
- Loading spinner during every async operation
|
||||||
|
- Disabled state on buttons during form submission
|
||||||
|
- Progress indicator for long operations (file upload)
|
||||||
|
- Toast/notification disappears after appropriate time
|
||||||
|
- Multiple notifications don't overlap incorrectly
|
||||||
|
- Success messages are specific (not just "Success")
|
||||||
|
|
||||||
|
### O. Responsive & Layout Tests
|
||||||
|
|
||||||
|
Test that the UI works on different screen sizes.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- Desktop layout correct at 1920px width
|
||||||
|
- Tablet layout correct at 768px width
|
||||||
|
- Mobile layout correct at 375px width
|
||||||
|
- No horizontal scroll on any standard viewport
|
||||||
|
- Touch targets large enough on mobile (44px min)
|
||||||
|
- Modals fit within viewport on mobile
|
||||||
|
- Long text truncates or wraps correctly (no overflow)
|
||||||
|
- Tables scroll horizontally if needed on mobile
|
||||||
|
- Navigation collapses appropriately on mobile
|
||||||
|
|
||||||
|
### P. Accessibility Tests
|
||||||
|
|
||||||
|
Test basic accessibility compliance.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- Tab navigation works through all interactive elements
|
||||||
|
- Focus ring visible on all focused elements
|
||||||
|
- Screen reader can navigate main content areas
|
||||||
|
- ARIA labels on icon-only buttons
|
||||||
|
- Color contrast meets WCAG AA (4.5:1 for text)
|
||||||
|
- No information conveyed by color alone
|
||||||
|
- Form fields have associated labels
|
||||||
|
- Error messages announced to screen readers
|
||||||
|
- Skip link to main content (if applicable)
|
||||||
|
- Images have alt text
|
||||||
|
|
||||||
|
### Q. Temporal & Timezone Tests
|
||||||
|
|
||||||
|
Test date/time handling.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- Dates display in user's local timezone
|
||||||
|
- Created/updated timestamps accurate and formatted correctly
|
||||||
|
- Date picker allows only valid date ranges
|
||||||
|
- Overdue items identified correctly (timezone-aware)
|
||||||
|
- "Today", "This Week" filters work correctly for user's timezone
|
||||||
|
- Recurring items generate at correct times (if applicable)
|
||||||
|
- Date sorting works correctly across months/years
|
||||||
|
|
||||||
|
### R. Concurrency & Race Condition Tests
|
||||||
|
|
||||||
|
Test multi-user and race condition scenarios.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- Two users edit same record - last save wins or conflict shown
|
||||||
|
- Record deleted while another user viewing - graceful handling
|
||||||
|
- List updates while user on page 2 - pagination still works
|
||||||
|
- Rapid navigation between pages - no stale data displayed
|
||||||
|
- API response arrives after user navigated away - no crash
|
||||||
|
- Concurrent form submissions from same user handled
|
||||||
|
|
||||||
|
### S. Export/Import Tests (if applicable)
|
||||||
|
|
||||||
|
Test data export and import functionality.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- Export all data - file contains all records
|
||||||
|
- Export filtered data - only filtered records included
|
||||||
|
- Import valid file - all records created correctly
|
||||||
|
- Import duplicate data - handled correctly (skip/update/error)
|
||||||
|
- Import malformed file - error message, no partial import
|
||||||
|
- Export then import - data integrity preserved exactly
|
||||||
|
|
||||||
|
### T. Performance Tests
|
||||||
|
|
||||||
|
Test basic performance requirements.
|
||||||
|
|
||||||
|
**Required tests (examples):**
|
||||||
|
|
||||||
|
- Page loads in <3s with 100 records
|
||||||
|
- Page loads in <5s with 1000 records
|
||||||
|
- Search responds in <1s
|
||||||
|
- Infinite scroll doesn't degrade with many items
|
||||||
|
- Large file upload shows progress
|
||||||
|
- Memory doesn't leak on long sessions
|
||||||
|
- No console errors during normal operation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ABSOLUTE PROHIBITION: NO MOCK DATA
|
||||||
|
|
||||||
|
The feature_list.json must include tests that **actively verify real data** and **detect mock data patterns**.
|
||||||
|
|
||||||
|
**Include these specific tests:**
|
||||||
|
|
||||||
|
1. Create unique test data (e.g., "TEST_12345_VERIFY_ME")
|
||||||
|
2. Verify that EXACT data appears in UI
|
||||||
|
3. Refresh page - data persists
|
||||||
|
4. Delete data - verify it's gone
|
||||||
|
5. If data appears that wasn't created during test - FLAG AS MOCK DATA
|
||||||
|
|
||||||
|
**The agent implementing features MUST NOT use:**
|
||||||
|
|
||||||
|
- Hardcoded arrays of fake data
|
||||||
|
- `mockData`, `fakeData`, `sampleData`, `dummyData` variables
|
||||||
|
- `// TODO: replace with real API`
|
||||||
|
- `setTimeout` simulating API delays with static data
|
||||||
|
- Static returns instead of database queries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**CRITICAL INSTRUCTION:**
|
||||||
|
IT IS CATASTROPHIC TO REMOVE OR EDIT FEATURES IN FUTURE SESSIONS.
|
||||||
|
Features can ONLY be marked as passing (via the `feature_mark_passing` tool with the feature_id).
|
||||||
|
Never remove features, never edit descriptions, never modify testing steps.
|
||||||
|
This ensures no functionality is missed.
|
||||||
|
|
||||||
|
### SECOND TASK: Create init.sh
|
||||||
|
|
||||||
|
Create a script called `init.sh` that future agents can use to quickly
|
||||||
|
set up and run the development environment. The script should:
|
||||||
|
|
||||||
|
1. Install any required dependencies
|
||||||
|
2. Start any necessary servers or services
|
||||||
|
3. Print helpful information about how to access the running application
|
||||||
|
|
||||||
|
Base the script on the technology stack specified in `app_spec.txt`.
|
||||||
|
|
||||||
|
### THIRD TASK: Initialize Git
|
||||||
|
|
||||||
|
Create a git repository and make your first commit with:
|
||||||
|
|
||||||
|
- init.sh (environment setup script)
|
||||||
|
- README.md (project overview and setup instructions)
|
||||||
|
- Any initial project structure files
|
||||||
|
|
||||||
|
Note: Features are stored in the SQLite database (features.db), not in a JSON file.
|
||||||
|
|
||||||
|
Commit message: "Initial setup: init.sh, project structure, and features created via API"
|
||||||
|
|
||||||
|
### FOURTH TASK: Create Project Structure
|
||||||
|
|
||||||
|
Set up the basic project structure based on what's specified in `app_spec.txt`.
|
||||||
|
This typically includes directories for frontend, backend, and any other
|
||||||
|
components mentioned in the spec.
|
||||||
|
|
||||||
|
### OPTIONAL: Start Implementation
|
||||||
|
|
||||||
|
If you have time remaining in this session, you may begin implementing
|
||||||
|
the highest-priority features. Get the next feature with:
|
||||||
|
|
||||||
|
```
|
||||||
|
Use the feature_get_next tool
|
||||||
|
```
|
||||||
|
|
||||||
|
Remember:
|
||||||
|
- Work on ONE feature at a time
|
||||||
|
- Test thoroughly before marking as passing
|
||||||
|
- Commit your progress before session ends
|
||||||
|
|
||||||
|
### ENDING THIS SESSION
|
||||||
|
|
||||||
|
Before your context fills up:
|
||||||
|
|
||||||
|
1. Commit all work with descriptive messages
|
||||||
|
2. Create `claude-progress.txt` with a summary of what you accomplished
|
||||||
|
3. Verify features were created using the feature_get_stats tool
|
||||||
|
4. Leave the environment in a clean, working state
|
||||||
|
|
||||||
|
The next agent will continue from here with a fresh context window.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Remember:** You have unlimited time across many sessions. Focus on
|
||||||
|
quality over speed. Production-ready is the goal.
|
||||||
@@ -11,3 +11,4 @@ python-multipart==0.0.6
|
|||||||
pydantic==2.10.6
|
pydantic==2.10.6
|
||||||
pydantic-settings==2.8.0
|
pydantic-settings==2.8.0
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
httpx==0.27.0
|
||||||
|
|||||||
404
temp/m365_security_scan.py
Normal file
404
temp/m365_security_scan.py
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
M365 Security Scan - Check all accounts for compromise indicators
|
||||||
|
Scans: Sign-in logs, inbox rules, OAuth grants, MFA methods, forwarding
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Claude-MSP-Access Multi-Tenant App
|
||||||
|
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
|
||||||
|
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
|
||||||
|
|
||||||
|
TENANTS = {
|
||||||
|
"Valley Wide Plastering": {
|
||||||
|
"tenant_id": "5c53ae9f-7071-4248-b834-8685b646450f",
|
||||||
|
"domain": "valleywideplastering.com"
|
||||||
|
},
|
||||||
|
"BG Builders LLC": {
|
||||||
|
"tenant_id": "ededa4fb-f6eb-4398-851d-5eb3e11fab27",
|
||||||
|
"domain": "bgbuildersllc.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Known suspicious patterns
|
||||||
|
SUSPICIOUS_RULE_PATTERNS = [".", "..", "...", "spam", "junk", "filter"]
|
||||||
|
SUSPICIOUS_OAUTH_APPS = ["gmail", "yahoo", "p2p", "autoforward"]
|
||||||
|
US_COUNTRY_CODES = ["US", "United States"]
|
||||||
|
|
||||||
|
def get_token(tenant_id):
|
||||||
|
"""Get Graph API access token for tenant"""
|
||||||
|
url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
||||||
|
data = {
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
"client_secret": CLIENT_SECRET,
|
||||||
|
"scope": "https://graph.microsoft.com/.default",
|
||||||
|
"grant_type": "client_credentials"
|
||||||
|
}
|
||||||
|
resp = requests.post(url, data=data)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json().get("access_token")
|
||||||
|
else:
|
||||||
|
print(f" [ERROR] Token failed: {resp.status_code} - {resp.text[:200]}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def graph_get(token, endpoint, params=None):
|
||||||
|
"""Make Graph API GET request"""
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
url = f"https://graph.microsoft.com/v1.0{endpoint}"
|
||||||
|
resp = requests.get(url, headers=headers, params=params)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()
|
||||||
|
elif resp.status_code == 404:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return {"error": resp.status_code, "message": resp.text[:200]}
|
||||||
|
|
||||||
|
def graph_get_beta(token, endpoint, params=None):
|
||||||
|
"""Make Graph API beta GET request"""
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
url = f"https://graph.microsoft.com/beta{endpoint}"
|
||||||
|
resp = requests.get(url, headers=headers, params=params)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()
|
||||||
|
elif resp.status_code == 404:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return {"error": resp.status_code, "message": resp.text[:200]}
|
||||||
|
|
||||||
|
def check_signin_logs(token, user_id, user_email, days=30):
|
||||||
|
"""Check sign-in logs for foreign/suspicious IPs"""
|
||||||
|
issues = []
|
||||||
|
cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
# Get sign-in logs
|
||||||
|
params = {
|
||||||
|
"$filter": f"userId eq '{user_id}' and createdDateTime ge {cutoff}",
|
||||||
|
"$top": 100,
|
||||||
|
"$orderby": "createdDateTime desc"
|
||||||
|
}
|
||||||
|
result = graph_get_beta(token, "/auditLogs/signIns", params)
|
||||||
|
|
||||||
|
if result and "value" in result:
|
||||||
|
foreign_logins = []
|
||||||
|
failed_foreign = []
|
||||||
|
|
||||||
|
for signin in result["value"]:
|
||||||
|
location = signin.get("location", {})
|
||||||
|
country = location.get("countryOrRegion", "Unknown")
|
||||||
|
status = signin.get("status", {})
|
||||||
|
error_code = status.get("errorCode", 0)
|
||||||
|
ip = signin.get("ipAddress", "Unknown")
|
||||||
|
|
||||||
|
if country not in US_COUNTRY_CODES and country != "Unknown":
|
||||||
|
entry = {
|
||||||
|
"ip": ip,
|
||||||
|
"country": country,
|
||||||
|
"city": location.get("city", "Unknown"),
|
||||||
|
"time": signin.get("createdDateTime"),
|
||||||
|
"success": error_code == 0,
|
||||||
|
"error": error_code
|
||||||
|
}
|
||||||
|
if error_code == 0:
|
||||||
|
foreign_logins.append(entry)
|
||||||
|
else:
|
||||||
|
failed_foreign.append(entry)
|
||||||
|
|
||||||
|
if foreign_logins:
|
||||||
|
issues.append({
|
||||||
|
"type": "FOREIGN_SUCCESS_LOGIN",
|
||||||
|
"severity": "CRITICAL",
|
||||||
|
"count": len(foreign_logins),
|
||||||
|
"details": foreign_logins[:5] # Top 5
|
||||||
|
})
|
||||||
|
|
||||||
|
if failed_foreign:
|
||||||
|
# Group by country
|
||||||
|
countries = list(set([f["country"] for f in failed_foreign]))
|
||||||
|
issues.append({
|
||||||
|
"type": "FOREIGN_FAILED_ATTEMPTS",
|
||||||
|
"severity": "INFO",
|
||||||
|
"count": len(failed_foreign),
|
||||||
|
"countries": countries
|
||||||
|
})
|
||||||
|
elif result and "error" in result:
|
||||||
|
if result["error"] != 404:
|
||||||
|
issues.append({"type": "SIGNIN_LOG_ERROR", "severity": "WARNING", "details": result})
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
def check_inbox_rules(token, user_id, user_email):
|
||||||
|
"""Check for malicious inbox rules"""
|
||||||
|
issues = []
|
||||||
|
result = graph_get(token, f"/users/{user_id}/mailFolders/inbox/messageRules")
|
||||||
|
|
||||||
|
if result and "value" in result:
|
||||||
|
for rule in result["value"]:
|
||||||
|
name = rule.get("displayName", "")
|
||||||
|
is_enabled = rule.get("isEnabled", False)
|
||||||
|
|
||||||
|
# Check for suspicious patterns
|
||||||
|
suspicious = False
|
||||||
|
reasons = []
|
||||||
|
|
||||||
|
# Short/dot names
|
||||||
|
if name in SUSPICIOUS_RULE_PATTERNS or len(name) <= 2:
|
||||||
|
suspicious = True
|
||||||
|
reasons.append(f"Suspicious name: '{name}'")
|
||||||
|
|
||||||
|
# Rules that delete/move and mark read
|
||||||
|
actions = rule.get("actions", {})
|
||||||
|
if actions.get("markAsRead") and (actions.get("delete") or actions.get("moveToFolder")):
|
||||||
|
suspicious = True
|
||||||
|
reasons.append("Marks read + moves/deletes")
|
||||||
|
|
||||||
|
# Stop processing
|
||||||
|
if actions.get("stopProcessingRules") and (actions.get("moveToFolder") or actions.get("delete")):
|
||||||
|
suspicious = True
|
||||||
|
reasons.append("Stops processing + hides mail")
|
||||||
|
|
||||||
|
# Forwarding rules
|
||||||
|
if actions.get("forwardTo") or actions.get("forwardAsAttachmentTo") or actions.get("redirectTo"):
|
||||||
|
forward_targets = actions.get("forwardTo", []) + actions.get("forwardAsAttachmentTo", []) + actions.get("redirectTo", [])
|
||||||
|
suspicious = True
|
||||||
|
reasons.append(f"Forwards to external: {forward_targets}")
|
||||||
|
|
||||||
|
if suspicious and is_enabled:
|
||||||
|
issues.append({
|
||||||
|
"type": "SUSPICIOUS_INBOX_RULE",
|
||||||
|
"severity": "CRITICAL",
|
||||||
|
"rule_name": name,
|
||||||
|
"rule_id": rule.get("id"),
|
||||||
|
"reasons": reasons
|
||||||
|
})
|
||||||
|
elif result and "error" in result:
|
||||||
|
if result["error"] != 404:
|
||||||
|
issues.append({"type": "INBOX_RULE_ERROR", "severity": "WARNING", "details": result})
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
def check_oauth_grants(token, user_id, user_email):
|
||||||
|
"""Check for suspicious OAuth app grants"""
|
||||||
|
issues = []
|
||||||
|
result = graph_get(token, f"/users/{user_id}/oauth2PermissionGrants")
|
||||||
|
|
||||||
|
if result and "value" in result:
|
||||||
|
for grant in result["value"]:
|
||||||
|
client_id = grant.get("clientId", "")
|
||||||
|
scope = grant.get("scope", "")
|
||||||
|
|
||||||
|
# Get app details
|
||||||
|
app_result = graph_get(token, f"/servicePrincipals/{client_id}")
|
||||||
|
app_name = app_result.get("displayName", "Unknown") if app_result else "Unknown"
|
||||||
|
|
||||||
|
# Check for suspicious apps
|
||||||
|
suspicious = False
|
||||||
|
for pattern in SUSPICIOUS_OAUTH_APPS:
|
||||||
|
if pattern.lower() in app_name.lower():
|
||||||
|
suspicious = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check for sensitive scopes
|
||||||
|
sensitive_scopes = ["Mail.ReadWrite", "Mail.Send", "MailboxSettings", "full_access"]
|
||||||
|
has_sensitive = any(s.lower() in scope.lower() for s in sensitive_scopes)
|
||||||
|
|
||||||
|
if suspicious or (has_sensitive and "Microsoft" not in app_name):
|
||||||
|
issues.append({
|
||||||
|
"type": "SUSPICIOUS_OAUTH_APP",
|
||||||
|
"severity": "HIGH" if suspicious else "MEDIUM",
|
||||||
|
"app_name": app_name,
|
||||||
|
"client_id": client_id,
|
||||||
|
"scope": scope
|
||||||
|
})
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
def check_mfa_methods(token, user_id, user_email):
|
||||||
|
"""Check MFA methods for suspicious devices"""
|
||||||
|
issues = []
|
||||||
|
result = graph_get(token, f"/users/{user_id}/authentication/methods")
|
||||||
|
|
||||||
|
if result and "value" in result:
|
||||||
|
methods = []
|
||||||
|
for method in result["value"]:
|
||||||
|
method_type = method.get("@odata.type", "")
|
||||||
|
if "phone" in method_type.lower():
|
||||||
|
phone = method.get("phoneNumber", "Unknown")
|
||||||
|
methods.append({"type": "phone", "value": phone})
|
||||||
|
elif "microsoftAuthenticator" in method_type:
|
||||||
|
device = method.get("displayName", method.get("deviceTag", "Unknown"))
|
||||||
|
methods.append({"type": "authenticator", "device": device})
|
||||||
|
elif "fido2" in method_type.lower():
|
||||||
|
methods.append({"type": "fido2", "model": method.get("model", "Unknown")})
|
||||||
|
|
||||||
|
# Flag if multiple authenticator devices (potential attacker device)
|
||||||
|
auth_devices = [m for m in methods if m.get("type") == "authenticator"]
|
||||||
|
if len(auth_devices) > 2:
|
||||||
|
issues.append({
|
||||||
|
"type": "MULTIPLE_AUTH_DEVICES",
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"count": len(auth_devices),
|
||||||
|
"devices": auth_devices
|
||||||
|
})
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
def check_mailbox_settings(token, user_id, user_email):
|
||||||
|
"""Check mailbox for forwarding/auto-replies"""
|
||||||
|
issues = []
|
||||||
|
result = graph_get(token, f"/users/{user_id}/mailboxSettings")
|
||||||
|
|
||||||
|
if result and "error" not in result:
|
||||||
|
# Check auto-forwarding
|
||||||
|
# Note: Graph API doesn't expose SMTP forwarding directly, need Exchange
|
||||||
|
|
||||||
|
# Check automatic replies
|
||||||
|
auto_reply = result.get("automaticRepliesSetting", {})
|
||||||
|
if auto_reply.get("status") == "alwaysEnabled":
|
||||||
|
issues.append({
|
||||||
|
"type": "AUTO_REPLY_ALWAYS_ON",
|
||||||
|
"severity": "LOW",
|
||||||
|
"message": auto_reply.get("internalReplyMessage", "")[:100]
|
||||||
|
})
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
def scan_tenant(tenant_name, tenant_info):
|
||||||
|
"""Scan all users in a tenant"""
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Scanning: {tenant_name}")
|
||||||
|
print(f"Tenant ID: {tenant_info['tenant_id']}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
token = get_token(tenant_info["tenant_id"])
|
||||||
|
if not token:
|
||||||
|
return {"error": "Failed to get token - admin consent may be needed"}
|
||||||
|
|
||||||
|
# Get all users
|
||||||
|
users_result = graph_get(token, "/users", {"$select": "id,displayName,mail,userPrincipalName,accountEnabled"})
|
||||||
|
if not users_result or "value" not in users_result:
|
||||||
|
return {"error": f"Failed to get users: {users_result}"}
|
||||||
|
|
||||||
|
users = users_result["value"]
|
||||||
|
print(f"Found {len(users)} users")
|
||||||
|
|
||||||
|
results = {
|
||||||
|
"tenant": tenant_name,
|
||||||
|
"scan_time": datetime.utcnow().isoformat(),
|
||||||
|
"total_users": len(users),
|
||||||
|
"clean_users": [],
|
||||||
|
"flagged_users": [],
|
||||||
|
"disabled_users": [],
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
user_id = user.get("id")
|
||||||
|
email = user.get("mail") or user.get("userPrincipalName", "Unknown")
|
||||||
|
name = user.get("displayName", "Unknown")
|
||||||
|
enabled = user.get("accountEnabled", True)
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
results["disabled_users"].append({"name": name, "email": email})
|
||||||
|
print(f" [SKIP] {name} - disabled")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" Checking: {name} ({email})...", end=" ")
|
||||||
|
|
||||||
|
all_issues = []
|
||||||
|
|
||||||
|
# Run all checks
|
||||||
|
try:
|
||||||
|
all_issues.extend(check_signin_logs(token, user_id, email))
|
||||||
|
except Exception as e:
|
||||||
|
results["errors"].append({"user": email, "check": "signin_logs", "error": str(e)})
|
||||||
|
|
||||||
|
try:
|
||||||
|
all_issues.extend(check_inbox_rules(token, user_id, email))
|
||||||
|
except Exception as e:
|
||||||
|
results["errors"].append({"user": email, "check": "inbox_rules", "error": str(e)})
|
||||||
|
|
||||||
|
try:
|
||||||
|
all_issues.extend(check_oauth_grants(token, user_id, email))
|
||||||
|
except Exception as e:
|
||||||
|
results["errors"].append({"user": email, "check": "oauth_grants", "error": str(e)})
|
||||||
|
|
||||||
|
try:
|
||||||
|
all_issues.extend(check_mfa_methods(token, user_id, email))
|
||||||
|
except Exception as e:
|
||||||
|
results["errors"].append({"user": email, "check": "mfa_methods", "error": str(e)})
|
||||||
|
|
||||||
|
try:
|
||||||
|
all_issues.extend(check_mailbox_settings(token, user_id, email))
|
||||||
|
except Exception as e:
|
||||||
|
results["errors"].append({"user": email, "check": "mailbox_settings", "error": str(e)})
|
||||||
|
|
||||||
|
# Categorize by severity
|
||||||
|
critical = [i for i in all_issues if i.get("severity") == "CRITICAL"]
|
||||||
|
high = [i for i in all_issues if i.get("severity") == "HIGH"]
|
||||||
|
|
||||||
|
if critical or high:
|
||||||
|
results["flagged_users"].append({
|
||||||
|
"name": name,
|
||||||
|
"email": email,
|
||||||
|
"user_id": user_id,
|
||||||
|
"issues": all_issues
|
||||||
|
})
|
||||||
|
print(f"[FLAGGED] {len(critical)} critical, {len(high)} high")
|
||||||
|
else:
|
||||||
|
results["clean_users"].append({"name": name, "email": email})
|
||||||
|
info_issues = [i for i in all_issues if i.get("severity") == "INFO"]
|
||||||
|
if info_issues:
|
||||||
|
print(f"[OK] ({len(info_issues)} info)")
|
||||||
|
else:
|
||||||
|
print("[OK]")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("M365 Security Scan")
|
||||||
|
print(f"Started: {datetime.utcnow().isoformat()}")
|
||||||
|
|
||||||
|
all_results = {}
|
||||||
|
|
||||||
|
for tenant_name, tenant_info in TENANTS.items():
|
||||||
|
try:
|
||||||
|
results = scan_tenant(tenant_name, tenant_info)
|
||||||
|
all_results[tenant_name] = results
|
||||||
|
except Exception as e:
|
||||||
|
all_results[tenant_name] = {"error": str(e)}
|
||||||
|
print(f" [ERROR] Tenant scan failed: {e}")
|
||||||
|
|
||||||
|
# Save results
|
||||||
|
output_file = "/Users/azcomputerguru/ClaudeTools/temp/m365_security_scan_results.json"
|
||||||
|
with open(output_file, "w") as f:
|
||||||
|
json.dump(all_results, f, indent=2)
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("SCAN SUMMARY")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
for tenant_name, results in all_results.items():
|
||||||
|
print(f"\n{tenant_name}:")
|
||||||
|
if "error" in results:
|
||||||
|
print(f" [ERROR] {results['error']}")
|
||||||
|
else:
|
||||||
|
print(f" Total users: {results['total_users']}")
|
||||||
|
print(f" Clean: {len(results['clean_users'])}")
|
||||||
|
print(f" Flagged: {len(results['flagged_users'])}")
|
||||||
|
print(f" Disabled: {len(results['disabled_users'])}")
|
||||||
|
|
||||||
|
if results["flagged_users"]:
|
||||||
|
print("\n FLAGGED ACCOUNTS:")
|
||||||
|
for user in results["flagged_users"]:
|
||||||
|
print(f" - {user['name']} ({user['email']})")
|
||||||
|
for issue in user["issues"]:
|
||||||
|
print(f" [{issue['severity']}] {issue['type']}")
|
||||||
|
|
||||||
|
print(f"\nResults saved to: {output_file}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
274
temp/m365_security_scan_results.json
Normal file
274
temp/m365_security_scan_results.json
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
{
|
||||||
|
"Valley Wide Plastering": {
|
||||||
|
"tenant": "Valley Wide Plastering",
|
||||||
|
"scan_time": "2026-03-06T01:21:31.514321",
|
||||||
|
"total_users": 33,
|
||||||
|
"clean_users": [
|
||||||
|
{
|
||||||
|
"name": "Adolfo Suarez",
|
||||||
|
"email": "adolfos@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Toni",
|
||||||
|
"email": "billing@valleywideplastering.onmicrosoft.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Brian",
|
||||||
|
"email": "Brian@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Carlos Reyes",
|
||||||
|
"email": "carlos@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Charlie Jones",
|
||||||
|
"email": "charlie@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Chris Guerrero",
|
||||||
|
"email": "chris@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Customer Service",
|
||||||
|
"email": "customerservice@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Customer Service",
|
||||||
|
"email": "customerservice@valleywideplastering.onmicrosoft.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bart Graffin",
|
||||||
|
"email": "estimating@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fax Inbox",
|
||||||
|
"email": "faxinbox@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fermin Matta",
|
||||||
|
"email": "fermin@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Francisco Arias",
|
||||||
|
"email": "franciscoa@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "VWP Insurance",
|
||||||
|
"email": "insurance@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Issac Chavez",
|
||||||
|
"email": "isaacc@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "JR Guerrero",
|
||||||
|
"email": "j-r@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jaime Hernandez",
|
||||||
|
"email": "jaimebh@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jesse Guerrero",
|
||||||
|
"email": "jesse@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "JR Guerrero",
|
||||||
|
"email": "jr@CASARICA.NET"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Juan Leal",
|
||||||
|
"email": "juan@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kayla Guerrero",
|
||||||
|
"email": "kayla@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Orders VWP",
|
||||||
|
"email": "orders@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Payroll VWP",
|
||||||
|
"email": "payroll@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ron Winger",
|
||||||
|
"email": "ron@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rose Guerrero",
|
||||||
|
"email": "rose@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ryan Guerrero",
|
||||||
|
"email": "ryan@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sammy Montijo",
|
||||||
|
"email": "sammy@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Shelly Dooley",
|
||||||
|
"email": "shelly@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Spro VWP",
|
||||||
|
"email": "spro@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Computer Guru",
|
||||||
|
"email": "sysadmin@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Teresa Carpio",
|
||||||
|
"email": "teresa@valleywideplastering.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ty Fetters",
|
||||||
|
"email": "Ty@CASARICA.NET"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"flagged_users": [
|
||||||
|
{
|
||||||
|
"name": "Accounts Payable",
|
||||||
|
"email": "acctpay@valleywideplastering.com",
|
||||||
|
"user_id": "e70d7ec5-72f3-4b80-9614-e6bd5380b773",
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"type": "SUSPICIOUS_INBOX_RULE",
|
||||||
|
"severity": "CRITICAL",
|
||||||
|
"rule_name": "Order Acknowledgment ",
|
||||||
|
"rule_id": "AQAAANfcAXQ=",
|
||||||
|
"reasons": [
|
||||||
|
"Stops processing + hides mail"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Billing Clerk",
|
||||||
|
"email": "billing@valleywideplastering.com",
|
||||||
|
"user_id": "4f708b80-e537-4f63-92d3-5feedfa28244",
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"type": "FOREIGN_FAILED_ATTEMPTS",
|
||||||
|
"severity": "INFO",
|
||||||
|
"count": 15,
|
||||||
|
"countries": [
|
||||||
|
"GN",
|
||||||
|
"SG",
|
||||||
|
"ID",
|
||||||
|
"CZ",
|
||||||
|
"CN",
|
||||||
|
"BR",
|
||||||
|
"IT",
|
||||||
|
"ZA",
|
||||||
|
"VN",
|
||||||
|
"PH",
|
||||||
|
"CA",
|
||||||
|
"AR",
|
||||||
|
"AL"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "SUSPICIOUS_INBOX_RULE",
|
||||||
|
"severity": "CRITICAL",
|
||||||
|
"rule_name": "Tim Wolf",
|
||||||
|
"rule_id": "AQAAAFDUDZY=",
|
||||||
|
"reasons": [
|
||||||
|
"Stops processing + hides mail"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "SUSPICIOUS_INBOX_RULE",
|
||||||
|
"severity": "CRITICAL",
|
||||||
|
"rule_name": "donotreply@pulte.com",
|
||||||
|
"rule_id": "AQAAADPeesE=",
|
||||||
|
"reasons": [
|
||||||
|
"Stops processing + hides mail"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "SUSPICIOUS_INBOX_RULE",
|
||||||
|
"severity": "CRITICAL",
|
||||||
|
"rule_name": "ssrs-donotreply@pulte.com",
|
||||||
|
"rule_id": "AQAAADJQZww=",
|
||||||
|
"reasons": [
|
||||||
|
"Stops processing + hides mail"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"disabled_users": [],
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
"BG Builders LLC": {
|
||||||
|
"tenant": "BG Builders LLC",
|
||||||
|
"scan_time": "2026-03-06T01:54:05.702139",
|
||||||
|
"total_users": 14,
|
||||||
|
"clean_users": [
|
||||||
|
{
|
||||||
|
"name": "Accounting",
|
||||||
|
"email": "Accounting@sonorangreenllc.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accounts Payable",
|
||||||
|
"email": "accountspayable@sonorangreenllc.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "admin",
|
||||||
|
"email": "admin@bgbuildersllc.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Balynda Western",
|
||||||
|
"email": "balynda@bgbuildersllc.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Barry Walling",
|
||||||
|
"email": "barry@bgbuildersllc.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Barry Walling",
|
||||||
|
"email": "Barry@sonorangreenllc.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Chad Bradford",
|
||||||
|
"email": "chad@bgbuildersllc.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Lesley Roth",
|
||||||
|
"email": "lesley@bgbuildersllc.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Projects",
|
||||||
|
"email": "projects@bgbuildersllc.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Raul Flores",
|
||||||
|
"email": "raul@bgbuildersllc.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Shelly Dooley",
|
||||||
|
"email": "Shelly@bgbuildersllc.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Site Operations",
|
||||||
|
"email": "siteoperations@bgbuildersllc.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Computer Guru",
|
||||||
|
"email": "sysadmin@bgbuildersllc.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"flagged_users": [],
|
||||||
|
"disabled_users": [
|
||||||
|
{
|
||||||
|
"name": "Shaun Smith",
|
||||||
|
"email": "Shaun@bgbuildersllc.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user