feat: add chat settings UI with agent status and quick replies
Some checks failed
ci/woodpecker/push/ci Pipeline failed

- Add AgentStatus model for tracking user availability (online/away/busy/offline)
- Add ChatQuickReply model for pre-written chat responses
- Add agent status toggle to seller account dropdown menu
- Add quick replies management page under CRM settings
- Create migration for chat_quick_replies, chat_attachments, agent_statuses tables
- Add API endpoint for updating agent status
This commit is contained in:
kelly
2025-12-15 16:08:27 -07:00
parent de3faece35
commit 1f08ea8f12
9 changed files with 610 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\AgentStatus;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class AgentStatusController extends Controller
{
public function update(Request $request): JsonResponse
{
$validated = $request->validate([
'business_id' => 'required|integer|exists:businesses,id',
'status' => ['required', Rule::in(array_keys(AgentStatus::statuses()))],
'status_message' => 'nullable|string|max:100',
]);
$user = $request->user();
// Verify user belongs to the business
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$agentStatus = AgentStatus::getOrCreate($user->id, $validated['business_id']);
$agentStatus->setStatus($validated['status'], $validated['status_message'] ?? null);
return response()->json([
'success' => true,
'status' => $agentStatus->status,
'status_label' => AgentStatus::statuses()[$agentStatus->status],
]);
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\ChatQuickReply;
use App\Models\Crm\CrmChannel;
use App\Models\Crm\CrmMessageTemplate;
use App\Models\Crm\CrmPipeline;
@@ -649,4 +650,81 @@ class CrmSettingsController extends Controller
return back()->with('success', 'Role deleted.');
}
/**
* Quick replies list
*/
public function quickReplies(Request $request, Business $business)
{
$quickReplies = ChatQuickReply::where('business_id', $business->id)
->orderBy('sort_order')
->orderBy('label')
->get();
$categories = $quickReplies->pluck('category')->filter()->unique()->values();
return view('seller.crm.settings.quick-replies.index', [
'business' => $business,
'quickReplies' => $quickReplies,
'categories' => $categories,
]);
}
/**
* Store new quick reply
*/
public function storeQuickReply(Request $request, Business $business)
{
$validated = $request->validate([
'label' => 'required|string|max:100',
'message' => 'required|string|max:2000',
'category' => 'nullable|string|max:50',
'is_active' => 'boolean',
]);
$validated['business_id'] = $business->id;
$validated['is_active'] = $request->boolean('is_active', true);
$validated['sort_order'] = ChatQuickReply::where('business_id', $business->id)->max('sort_order') + 1;
ChatQuickReply::create($validated);
return back()->with('success', 'Quick reply created.');
}
/**
* Update quick reply
*/
public function updateQuickReply(Request $request, Business $business, ChatQuickReply $quickReply)
{
if ($quickReply->business_id !== $business->id) {
abort(404);
}
$validated = $request->validate([
'label' => 'required|string|max:100',
'message' => 'required|string|max:2000',
'category' => 'nullable|string|max:50',
'is_active' => 'boolean',
]);
$validated['is_active'] = $request->boolean('is_active', true);
$quickReply->update($validated);
return back()->with('success', 'Quick reply updated.');
}
/**
* Delete quick reply
*/
public function destroyQuickReply(Request $request, Business $business, ChatQuickReply $quickReply)
{
if ($quickReply->business_id !== $business->id) {
abort(404);
}
$quickReply->delete();
return back()->with('success', 'Quick reply deleted.');
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AgentStatus extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'business_id',
'status',
'status_message',
'last_seen_at',
'status_changed_at',
];
protected $casts = [
'last_seen_at' => 'datetime',
'status_changed_at' => 'datetime',
];
public const STATUS_ONLINE = 'online';
public const STATUS_AWAY = 'away';
public const STATUS_BUSY = 'busy';
public const STATUS_OFFLINE = 'offline';
public static function statuses(): array
{
return [
self::STATUS_ONLINE => 'Online',
self::STATUS_AWAY => 'Away',
self::STATUS_BUSY => 'Busy',
self::STATUS_OFFLINE => 'Offline',
];
}
public static function statusColors(): array
{
return [
self::STATUS_ONLINE => 'success',
self::STATUS_AWAY => 'warning',
self::STATUS_BUSY => 'error',
self::STATUS_OFFLINE => 'ghost',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public static function getOrCreate(int $userId, int $businessId): self
{
return self::firstOrCreate(
['user_id' => $userId, 'business_id' => $businessId],
['status' => self::STATUS_OFFLINE, 'status_changed_at' => now()]
);
}
public function setStatus(string $status, ?string $message = null): self
{
$this->update([
'status' => $status,
'status_message' => $message,
'status_changed_at' => now(),
]);
return $this;
}
public function isOnline(): bool
{
return $this->status === self::STATUS_ONLINE;
}
public function getStatusColor(): string
{
return self::statusColors()[$this->status] ?? 'ghost';
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ChatQuickReply extends Model
{
use HasFactory;
protected $table = 'chat_quick_replies';
protected $fillable = [
'business_id',
'label',
'message',
'category',
'usage_count',
'is_active',
'sort_order',
];
protected $casts = [
'is_active' => 'boolean',
'usage_count' => 'integer',
'sort_order' => 'integer',
];
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeByCategory($query, string $category)
{
return $query->where('category', $category);
}
public function incrementUsage(): void
{
$this->increment('usage_count');
}
}

View File

@@ -0,0 +1,103 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Add rating fields to crm_threads (skip if already exist)
if (!Schema::hasColumn('crm_threads', 'rating')) {
Schema::table('crm_threads', function (Blueprint $table) {
$table->unsignedTinyInteger('rating')->nullable();
});
}
if (!Schema::hasColumn('crm_threads', 'rating_comment')) {
Schema::table('crm_threads', function (Blueprint $table) {
$table->text('rating_comment')->nullable();
});
}
if (!Schema::hasColumn('crm_threads', 'rated_at')) {
Schema::table('crm_threads', function (Blueprint $table) {
$table->timestamp('rated_at')->nullable();
});
}
if (!Schema::hasColumn('crm_threads', 'ai_summary')) {
Schema::table('crm_threads', function (Blueprint $table) {
$table->text('ai_summary')->nullable();
});
}
if (!Schema::hasColumn('crm_threads', 'summary_generated_at')) {
Schema::table('crm_threads', function (Blueprint $table) {
$table->timestamp('summary_generated_at')->nullable();
});
}
// Create chat_attachments table
if (!Schema::hasTable('chat_attachments')) {
Schema::create('chat_attachments', function (Blueprint $table) {
$table->id();
$table->foreignId('message_id')->constrained('crm_channel_messages')->cascadeOnDelete();
$table->foreignId('business_id')->constrained()->cascadeOnDelete();
$table->string('filename');
$table->string('original_filename');
$table->string('mime_type');
$table->unsignedBigInteger('size');
$table->string('path');
$table->string('disk')->default('minio');
$table->json('metadata')->nullable();
$table->timestamps();
$table->index(['message_id']);
$table->index(['business_id']);
});
}
// Create quick_replies table
if (!Schema::hasTable('chat_quick_replies')) {
Schema::create('chat_quick_replies', function (Blueprint $table) {
$table->id();
$table->foreignId('business_id')->constrained()->cascadeOnDelete();
$table->string('label');
$table->text('message');
$table->string('category')->nullable();
$table->unsignedInteger('usage_count')->default(0);
$table->boolean('is_active')->default(true);
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
$table->index(['business_id', 'is_active']);
});
}
// Create agent_statuses table for tracking availability
if (!Schema::hasTable('agent_statuses')) {
Schema::create('agent_statuses', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('business_id')->constrained()->cascadeOnDelete();
$table->string('status')->default('offline'); // online, away, busy, offline
$table->string('status_message')->nullable();
$table->timestamp('last_seen_at')->nullable();
$table->timestamp('status_changed_at')->nullable();
$table->timestamps();
$table->unique(['user_id', 'business_id']);
$table->index(['business_id', 'status']);
});
}
}
public function down(): void
{
Schema::table('crm_threads', function (Blueprint $table) {
$table->dropColumn(['rating', 'rating_comment', 'rated_at', 'ai_summary', 'summary_generated_at']);
});
Schema::dropIfExists('chat_attachments');
Schema::dropIfExists('chat_quick_replies');
Schema::dropIfExists('agent_statuses');
}
};

View File

@@ -18,6 +18,17 @@
$isOwner = $business && $business->owner_user_id === $user->id;
$isSuperAdmin = $user->user_type === 'admin';
$canManageSettings = $isOwner || $isSuperAdmin;
// Get agent status for chat availability (sellers only)
$agentStatus = null;
if ($business && $user->user_type === 'seller') {
$agentStatus = \App\Models\AgentStatus::where('user_id', $user->id)
->where('business_id', $business->id)
->first();
}
$currentStatus = $agentStatus?->status ?? 'offline';
$statusColors = \App\Models\AgentStatus::statusColors();
$statuses = \App\Models\AgentStatus::statuses();
@endphp
<div x-data="{ accountOpen: false }" class="relative">
@@ -80,6 +91,88 @@
</a>
</li>
{{-- Chat Availability (Sellers only) --}}
@if($business && $user->user_type === 'seller')
<li class="pt-2 mt-2 border-t border-base-200">
<span class="px-3 text-xs font-semibold text-base-content/40 uppercase tracking-wider">Chat Availability</span>
</li>
<li x-data="{
status: '{{ $currentStatus }}',
updating: false,
async setStatus(newStatus) {
if (this.updating || this.status === newStatus) return;
this.updating = true;
try {
const response = await fetch('{{ route('api.agent-status.update') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content
},
body: JSON.stringify({ status: newStatus, business_id: {{ $business->id }} })
});
if (response.ok) {
this.status = newStatus;
}
} catch (e) {
console.error('Failed to update status', e);
}
this.updating = false;
}
}">
<div class="px-3 py-2 flex flex-wrap gap-1.5">
{{-- Online --}}
<button
@click="setStatus('online')"
:disabled="updating"
class="badge badge-sm cursor-pointer transition-all"
:class="status === 'online'
? 'badge-success ring-2 ring-success/30'
: 'badge-ghost hover:badge-success'">
<span class="size-1.5 rounded-full mr-1"
:class="status === 'online' ? 'bg-success-content' : 'bg-success'"></span>
Online
</button>
{{-- Away --}}
<button
@click="setStatus('away')"
:disabled="updating"
class="badge badge-sm cursor-pointer transition-all"
:class="status === 'away'
? 'badge-warning ring-2 ring-warning/30'
: 'badge-ghost hover:badge-warning'">
<span class="size-1.5 rounded-full mr-1"
:class="status === 'away' ? 'bg-warning-content' : 'bg-warning'"></span>
Away
</button>
{{-- Busy --}}
<button
@click="setStatus('busy')"
:disabled="updating"
class="badge badge-sm cursor-pointer transition-all"
:class="status === 'busy'
? 'badge-error ring-2 ring-error/30'
: 'badge-ghost hover:badge-error'">
<span class="size-1.5 rounded-full mr-1"
:class="status === 'busy' ? 'bg-error-content' : 'bg-error'"></span>
Busy
</button>
{{-- Offline --}}
<button
@click="setStatus('offline')"
:disabled="updating"
class="badge badge-sm cursor-pointer transition-all"
:class="status === 'offline'
? 'badge-neutral ring-2 ring-neutral/30'
: 'badge-ghost hover:badge-neutral'">
<span class="size-1.5 rounded-full mr-1"
:class="status === 'offline' ? 'bg-neutral-content' : 'bg-base-content/30'"></span>
Offline
</button>
</div>
</li>
@endif
{{-- Admin Console Section (Owner/Admin Only) --}}
@if($canManageSettings && $business)
<li class="pt-2 mt-2 border-t border-base-200">

View File

@@ -0,0 +1,148 @@
@extends('layouts.app-with-sidebar')
@section('content')
<div class="nx-shell">
<div class="nx-page">
<div class="p-6">
<div class="max-w-4xl mx-auto">
<div class="flex items-center gap-4 mb-6">
<a href="{{ route('seller.business.crm.settings.index', $business) }}" class="btn btn-ghost btn-sm btn-circle">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</a>
<div>
<h1 class="text-2xl font-bold">Quick Replies</h1>
<p class="text-sm text-base-content/60">Pre-written responses for faster customer communication</p>
</div>
</div>
{{-- Create Quick Reply Form --}}
<div class="card bg-base-200 mb-6">
<div class="card-body">
<h2 class="card-title text-lg">Create Quick Reply</h2>
<form method="POST" action="{{ route('seller.business.crm.settings.quick-replies.store', $business) }}" class="space-y-4">
@csrf
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label py-1"><span class="label-text">Label</span></label>
<input type="text" name="label" required class="input input-bordered input-sm" placeholder="e.g. Greeting, Thanks, Follow-up" />
<label class="label py-0"><span class="label-text-alt text-base-content/50">Short name to identify this reply</span></label>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text">Category (optional)</span></label>
<input type="text" name="category" class="input input-bordered input-sm" placeholder="e.g. Support, Sales, General" list="category-suggestions" />
<datalist id="category-suggestions">
@foreach($categories as $cat)
<option value="{{ $cat }}">
@endforeach
</datalist>
</div>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text">Message</span></label>
<textarea name="message" required rows="3" class="textarea textarea-bordered text-sm" placeholder="Type your quick reply message here..."></textarea>
<label class="label py-0"><span class="label-text-alt text-base-content/50">Use @{{name}} for customer name, @{{product}} for product references</span></label>
</div>
<div class="flex items-center justify-between">
<label class="label cursor-pointer gap-2">
<input type="checkbox" name="is_active" value="1" checked class="checkbox checkbox-sm checkbox-primary" />
<span class="label-text">Active</span>
</label>
<button type="submit" class="btn btn-primary btn-sm">Add Quick Reply</button>
</div>
</form>
</div>
</div>
{{-- Existing Quick Replies --}}
<div class="card bg-base-200">
<div class="card-body">
<h2 class="card-title text-lg">Your Quick Replies</h2>
@if($quickReplies->count() > 0)
<div class="space-y-3">
@foreach($quickReplies as $reply)
<div class="bg-base-100 rounded-lg p-4" x-data="{ editing: false }">
{{-- View Mode --}}
<div x-show="!editing" class="flex items-start gap-4">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold">{{ $reply->label }}</span>
@if($reply->category)
<span class="badge badge-ghost badge-sm">{{ $reply->category }}</span>
@endif
@if(!$reply->is_active)
<span class="badge badge-warning badge-sm">Inactive</span>
@endif
</div>
<p class="text-sm text-base-content/70 whitespace-pre-wrap">{{ $reply->message }}</p>
<div class="mt-2 text-xs text-base-content/50">
Used {{ $reply->usage_count }} times
</div>
</div>
<div class="flex gap-2">
<button @click="editing = true" class="btn btn-ghost btn-sm btn-square">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<form method="POST" action="{{ route('seller.business.crm.settings.quick-replies.destroy', [$business, $reply]) }}"
onsubmit="return confirm('Delete this quick reply?')">
@csrf @method('DELETE')
<button type="submit" class="btn btn-ghost btn-sm btn-square text-error">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</form>
</div>
</div>
{{-- Edit Mode --}}
<form x-show="editing" x-cloak method="POST" action="{{ route('seller.business.crm.settings.quick-replies.update', [$business, $reply]) }}" class="space-y-3">
@csrf @method('PATCH')
<div class="grid grid-cols-2 gap-3">
<div class="form-control">
<label class="label py-1"><span class="label-text">Label</span></label>
<input type="text" name="label" value="{{ $reply->label }}" required class="input input-bordered input-sm" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text">Category</span></label>
<input type="text" name="category" value="{{ $reply->category }}" class="input input-bordered input-sm" list="category-suggestions" />
</div>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text">Message</span></label>
<textarea name="message" required rows="3" class="textarea textarea-bordered text-sm">{{ $reply->message }}</textarea>
</div>
<div class="flex items-center justify-between">
<label class="label cursor-pointer gap-2">
<input type="checkbox" name="is_active" value="1" {{ $reply->is_active ? 'checked' : '' }} class="checkbox checkbox-sm checkbox-primary" />
<span class="label-text">Active</span>
</label>
<div class="flex gap-2">
<button type="button" @click="editing = false" class="btn btn-ghost btn-sm">Cancel</button>
<button type="submit" class="btn btn-primary btn-sm">Save</button>
</div>
</div>
</form>
</div>
@endforeach
</div>
@else
<div class="text-center py-8 text-base-content/50">
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
<p>No quick replies yet.</p>
<p class="text-sm">Create your first one above to speed up customer responses!</p>
</div>
@endif
</div>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -1027,6 +1027,14 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
Route::patch('/{role}', [\App\Http\Controllers\Seller\Crm\CrmSettingsController::class, 'updateTeamRole'])->name('update');
Route::delete('/{role}', [\App\Http\Controllers\Seller\Crm\CrmSettingsController::class, 'destroyTeamRole'])->name('destroy');
});
// Quick Replies - chat quick reply templates
Route::prefix('quick-replies')->name('quick-replies.')->group(function () {
Route::get('/', [\App\Http\Controllers\Seller\Crm\CrmSettingsController::class, 'quickReplies'])->name('index');
Route::post('/', [\App\Http\Controllers\Seller\Crm\CrmSettingsController::class, 'storeQuickReply'])->name('store');
Route::patch('/{quickReply}', [\App\Http\Controllers\Seller\Crm\CrmSettingsController::class, 'updateQuickReply'])->name('update');
Route::delete('/{quickReply}', [\App\Http\Controllers\Seller\Crm\CrmSettingsController::class, 'destroyQuickReply'])->name('destroy');
});
});
});

View File

@@ -141,6 +141,9 @@ Route::middleware('auth')->group(function () {
// View As (impersonation) routes
Route::post('/view-as/end', [\App\Http\Controllers\ViewAsController::class, 'end'])->name('view-as.end');
Route::get('/view-as/status', [\App\Http\Controllers\ViewAsController::class, 'status'])->name('view-as.status');
// Agent status for chat availability
Route::post('/api/agent-status', [\App\Http\Controllers\Api\AgentStatusController::class, 'update'])->name('api.agent-status.update');
});
// Marketplace routes (browsing other businesses - marketplace style)