Compare commits
1 Commits
2026.03.06
...
2026.03.06
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cc6fa8c3c |
@@ -211,44 +211,41 @@ class TWP_Mobile_API {
|
|||||||
global $wpdb;
|
global $wpdb;
|
||||||
$queues_table = $wpdb->prefix . 'twp_call_queues';
|
$queues_table = $wpdb->prefix . 'twp_call_queues';
|
||||||
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
||||||
$assignments_table = $wpdb->prefix . 'twp_queue_assignments';
|
$groups_table = $wpdb->prefix . 'twp_group_members';
|
||||||
|
|
||||||
// Get queues assigned to this user
|
// Auto-create personal queues if they don't exist
|
||||||
$queue_ids = $wpdb->get_col($wpdb->prepare(
|
$extensions_table = $wpdb->prefix . 'twp_user_extensions';
|
||||||
"SELECT queue_id FROM $assignments_table WHERE user_id = %d",
|
$existing_extension = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT extension FROM $extensions_table WHERE user_id = %d",
|
||||||
$user_id
|
$user_id
|
||||||
));
|
));
|
||||||
|
|
||||||
// Also include personal queues
|
if (!$existing_extension) {
|
||||||
$personal_queue_ids = $wpdb->get_col($wpdb->prepare(
|
TWP_User_Queue_Manager::create_user_queues($user_id);
|
||||||
"SELECT id FROM $queues_table WHERE user_id = %d",
|
|
||||||
$user_id
|
|
||||||
));
|
|
||||||
|
|
||||||
$all_queue_ids = array_unique(array_merge($queue_ids, $personal_queue_ids));
|
|
||||||
|
|
||||||
if (empty($all_queue_ids)) {
|
|
||||||
return new WP_REST_Response(array(
|
|
||||||
'success' => true,
|
|
||||||
'queues' => array()
|
|
||||||
), 200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids));
|
// Get queues where user is a member of the assigned agent group OR personal/hold queues
|
||||||
|
$queues = $wpdb->get_results($wpdb->prepare("
|
||||||
// Get queue information with call counts
|
SELECT DISTINCT
|
||||||
$queues = $wpdb->get_results("
|
|
||||||
SELECT
|
|
||||||
q.id,
|
q.id,
|
||||||
q.queue_name,
|
q.queue_name,
|
||||||
q.queue_type,
|
q.queue_type,
|
||||||
q.extension,
|
q.extension,
|
||||||
COUNT(c.id) as waiting_count
|
COUNT(c.id) as waiting_count
|
||||||
FROM $queues_table q
|
FROM $queues_table q
|
||||||
|
LEFT JOIN $groups_table gm ON gm.group_id = q.agent_group_id
|
||||||
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
|
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
|
||||||
WHERE q.id IN ($queue_ids_str)
|
WHERE (gm.user_id = %d AND gm.is_active = 1)
|
||||||
|
OR (q.user_id = %d AND q.queue_type IN ('personal', 'hold'))
|
||||||
GROUP BY q.id
|
GROUP BY q.id
|
||||||
");
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN q.queue_type = 'personal' THEN 1
|
||||||
|
WHEN q.queue_type = 'hold' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
q.queue_name ASC
|
||||||
|
", $user_id, $user_id));
|
||||||
|
|
||||||
$result = array();
|
$result = array();
|
||||||
foreach ($queues as $queue) {
|
foreach ($queues as $queue) {
|
||||||
|
|||||||
@@ -142,38 +142,40 @@ class TWP_Mobile_SSE {
|
|||||||
global $wpdb;
|
global $wpdb;
|
||||||
$queues_table = $wpdb->prefix . 'twp_call_queues';
|
$queues_table = $wpdb->prefix . 'twp_call_queues';
|
||||||
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
$calls_table = $wpdb->prefix . 'twp_queued_calls';
|
||||||
$assignments_table = $wpdb->prefix . 'twp_queue_assignments';
|
$groups_table = $wpdb->prefix . 'twp_group_members';
|
||||||
|
|
||||||
// Get queue IDs
|
// Auto-create personal queues if they don't exist
|
||||||
$queue_ids = $wpdb->get_col($wpdb->prepare(
|
$extensions_table = $wpdb->prefix . 'twp_user_extensions';
|
||||||
"SELECT queue_id FROM $assignments_table WHERE user_id = %d",
|
$existing_extension = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT extension FROM $extensions_table WHERE user_id = %d",
|
||||||
$user_id
|
$user_id
|
||||||
));
|
));
|
||||||
|
|
||||||
$personal_queue_ids = $wpdb->get_col($wpdb->prepare(
|
if (!$existing_extension) {
|
||||||
"SELECT id FROM $queues_table WHERE user_id = %d",
|
TWP_User_Queue_Manager::create_user_queues($user_id);
|
||||||
$user_id
|
|
||||||
));
|
|
||||||
|
|
||||||
$all_queue_ids = array_unique(array_merge($queue_ids, $personal_queue_ids));
|
|
||||||
|
|
||||||
if (empty($all_queue_ids)) {
|
|
||||||
return array();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$queue_ids_str = implode(',', array_map('intval', $all_queue_ids));
|
// Get queues where user is a member of the assigned agent group OR personal/hold queues
|
||||||
|
$queues = $wpdb->get_results($wpdb->prepare("
|
||||||
$queues = $wpdb->get_results("
|
SELECT DISTINCT
|
||||||
SELECT
|
|
||||||
q.id,
|
q.id,
|
||||||
q.queue_name,
|
q.queue_name,
|
||||||
COUNT(c.id) as waiting_count,
|
COUNT(c.id) as waiting_count,
|
||||||
MIN(c.enqueued_at) as oldest_call_time
|
MIN(c.enqueued_at) as oldest_call_time
|
||||||
FROM $queues_table q
|
FROM $queues_table q
|
||||||
|
LEFT JOIN $groups_table gm ON gm.group_id = q.agent_group_id
|
||||||
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
|
LEFT JOIN $calls_table c ON q.id = c.queue_id AND c.status = 'waiting'
|
||||||
WHERE q.id IN ($queue_ids_str)
|
WHERE (gm.user_id = %d AND gm.is_active = 1)
|
||||||
|
OR (q.user_id = %d AND q.queue_type IN ('personal', 'hold'))
|
||||||
GROUP BY q.id
|
GROUP BY q.id
|
||||||
");
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN q.queue_type = 'personal' THEN 1
|
||||||
|
WHEN q.queue_type = 'hold' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
q.queue_name ASC
|
||||||
|
", $user_id, $user_id));
|
||||||
|
|
||||||
$result = array();
|
$result = array();
|
||||||
foreach ($queues as $queue) {
|
foreach ($queues as $queue) {
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ class AgentStatus {
|
|||||||
|
|
||||||
factory AgentStatus.fromJson(Map<String, dynamic> json) {
|
factory AgentStatus.fromJson(Map<String, dynamic> json) {
|
||||||
return AgentStatus(
|
return AgentStatus(
|
||||||
status: _parseStatus(json['status'] as String),
|
status: _parseStatus((json['status'] ?? 'offline') as String),
|
||||||
isLoggedIn: json['is_logged_in'] as bool,
|
isLoggedIn: json['is_logged_in'] == true || json['is_logged_in'] == 1 || json['is_logged_in'] == '1',
|
||||||
currentCallSid: json['current_call_sid'] as String?,
|
currentCallSid: json['current_call_sid'] as String?,
|
||||||
lastActivity: json['last_activity'] as String?,
|
lastActivity: json['last_activity'] as String?,
|
||||||
availableForQueues: json['available_for_queues'] as bool? ?? true,
|
availableForQueues: json['available_for_queues'] != false && json['available_for_queues'] != 0 && json['available_for_queues'] != '0',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,19 @@ class QueueInfo {
|
|||||||
|
|
||||||
factory QueueInfo.fromJson(Map<String, dynamic> json) {
|
factory QueueInfo.fromJson(Map<String, dynamic> json) {
|
||||||
return QueueInfo(
|
return QueueInfo(
|
||||||
id: json['id'] as int,
|
id: _toInt(json['id']),
|
||||||
name: json['name'] as String,
|
name: (json['name'] ?? '') as String,
|
||||||
type: json['type'] as String,
|
type: (json['type'] ?? '') as String,
|
||||||
extension: json['extension'] as String?,
|
extension: json['extension'] as String?,
|
||||||
waitingCount: json['waiting_count'] as int,
|
waitingCount: _toInt(json['waiting_count']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int _toInt(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is String) return int.tryParse(value) ?? 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class QueueCall {
|
class QueueCall {
|
||||||
@@ -43,12 +49,18 @@ class QueueCall {
|
|||||||
|
|
||||||
factory QueueCall.fromJson(Map<String, dynamic> json) {
|
factory QueueCall.fromJson(Map<String, dynamic> json) {
|
||||||
return QueueCall(
|
return QueueCall(
|
||||||
callSid: json['call_sid'] as String,
|
callSid: (json['call_sid'] ?? '') as String,
|
||||||
fromNumber: json['from_number'] as String,
|
fromNumber: (json['from_number'] ?? '') as String,
|
||||||
toNumber: json['to_number'] as String,
|
toNumber: (json['to_number'] ?? '') as String,
|
||||||
position: json['position'] as int,
|
position: _toInt(json['position']),
|
||||||
status: json['status'] as String,
|
status: (json['status'] ?? '') as String,
|
||||||
waitTime: json['wait_time'] as int,
|
waitTime: _toInt(json['wait_time']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int _toInt(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is String) return int.tryParse(value) ?? 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,16 @@ class User {
|
|||||||
|
|
||||||
factory User.fromJson(Map<String, dynamic> json) {
|
factory User.fromJson(Map<String, dynamic> json) {
|
||||||
return User(
|
return User(
|
||||||
id: json['user_id'] as int,
|
id: _toInt(json['user_id']),
|
||||||
login: json['user_login'] as String,
|
login: (json['user_login'] ?? '') as String,
|
||||||
displayName: json['display_name'] as String,
|
displayName: (json['display_name'] ?? '') as String,
|
||||||
email: json['email'] as String?,
|
email: json['email'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int _toInt(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is String) return int.tryParse(value) ?? 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,15 @@ class CallProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> sendDigits(String digits) => _voiceService.sendDigits(digits);
|
Future<void> sendDigits(String digits) => _voiceService.sendDigits(digits);
|
||||||
|
|
||||||
|
Future<void> makeCall(String number) async {
|
||||||
|
_callInfo = _callInfo.copyWith(
|
||||||
|
state: CallState.connecting,
|
||||||
|
callerNumber: number,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
await _voiceService.makeCall(number);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> holdCall() async {
|
Future<void> holdCall() async {
|
||||||
final sid = _callInfo.callSid;
|
final sid = _callInfo.callSid;
|
||||||
if (sid == null) return;
|
if (sid == null) return;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../providers/agent_provider.dart';
|
import '../providers/agent_provider.dart';
|
||||||
import '../providers/call_provider.dart';
|
import '../providers/call_provider.dart';
|
||||||
import '../widgets/agent_status_toggle.dart';
|
import '../widgets/agent_status_toggle.dart';
|
||||||
|
import '../widgets/dialpad.dart';
|
||||||
import '../widgets/queue_card.dart';
|
import '../widgets/queue_card.dart';
|
||||||
import 'active_call_screen.dart';
|
import 'active_call_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
@@ -23,6 +24,90 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showDialer(BuildContext context) {
|
||||||
|
final numberController = TextEditingController();
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (ctx) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(ctx).viewInsets.bottom,
|
||||||
|
top: 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Number display
|
||||||
|
TextField(
|
||||||
|
controller: numberController,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
autofillHints: const [AutofillHints.telephoneNumber],
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(ctx).textTheme.headlineSmall,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Enter phone number',
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: const Icon(Icons.backspace_outlined),
|
||||||
|
onPressed: () {
|
||||||
|
final text = numberController.text;
|
||||||
|
if (text.isNotEmpty) {
|
||||||
|
numberController.text =
|
||||||
|
text.substring(0, text.length - 1);
|
||||||
|
numberController.selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(offset: numberController.text.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Dialpad
|
||||||
|
Dialpad(
|
||||||
|
onDigit: (digit) {
|
||||||
|
numberController.text += digit;
|
||||||
|
numberController.selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(offset: numberController.text.length),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onClose: () => Navigator.pop(ctx),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Call button
|
||||||
|
ElevatedButton.icon(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size(double.infinity, 48),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.call),
|
||||||
|
label: const Text('Call'),
|
||||||
|
onPressed: () {
|
||||||
|
final number = numberController.text.trim();
|
||||||
|
if (number.isNotEmpty) {
|
||||||
|
context.read<CallProvider>().makeCall(number);
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final agent = context.watch<AgentProvider>();
|
final agent = context.watch<AgentProvider>();
|
||||||
@@ -58,6 +143,10 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () => _showDialer(context),
|
||||||
|
child: const Icon(Icons.phone),
|
||||||
|
),
|
||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
onRefresh: () => agent.refresh(),
|
onRefresh: () => agent.refresh(),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
@@ -39,6 +40,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
serverUrl = 'https://$serverUrl';
|
serverUrl = 'https://$serverUrl';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
context.read<AuthProvider>().login(
|
context.read<AuthProvider>().login(
|
||||||
serverUrl,
|
serverUrl,
|
||||||
_usernameController.text.trim(),
|
_usernameController.text.trim(),
|
||||||
@@ -55,6 +57,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: AutofillGroup(
|
||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -80,6 +83,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
|
autofillHints: const [AutofillHints.url],
|
||||||
validator: (v) =>
|
validator: (v) =>
|
||||||
v == null || v.trim().isEmpty ? 'Required' : null,
|
v == null || v.trim().isEmpty ? 'Required' : null,
|
||||||
),
|
),
|
||||||
@@ -91,6 +95,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
prefixIcon: Icon(Icons.person),
|
prefixIcon: Icon(Icons.person),
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
|
autofillHints: const [AutofillHints.username],
|
||||||
validator: (v) =>
|
validator: (v) =>
|
||||||
v == null || v.trim().isEmpty ? 'Required' : null,
|
v == null || v.trim().isEmpty ? 'Required' : null,
|
||||||
),
|
),
|
||||||
@@ -110,6 +115,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
obscureText: _obscurePassword,
|
obscureText: _obscurePassword,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
validator: (v) =>
|
validator: (v) =>
|
||||||
v == null || v.isEmpty ? 'Required' : null,
|
v == null || v.isEmpty ? 'Required' : null,
|
||||||
),
|
),
|
||||||
@@ -145,6 +151,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ class VoiceService {
|
|||||||
await TwilioVoice.instance.call.toggleSpeaker(speaker);
|
await TwilioVoice.instance.call.toggleSpeaker(speaker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> makeCall(String to) async {
|
||||||
|
return await TwilioVoice.instance.call.place(to: to, from: _identity ?? '') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> sendDigits(String digits) async {
|
Future<void> sendDigits(String digits) async {
|
||||||
await TwilioVoice.instance.call.sendDigits(digits);
|
await TwilioVoice.instance.call.sendDigits(digits);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user