ok
This commit is contained in:
@@ -1,9 +1,76 @@
|
|||||||
// DiscussedTopics.js
|
// DiscussedTopics.js
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import styles from './DiscussedTopics.module.css';
|
import styles from './DiscussedTopics.module.css';
|
||||||
|
|
||||||
const DiscussedTopics = ({ topics, faceAnalystList }) => {
|
const IMG_BASE = 'https://bot.kediritechnopark.com/webhook';
|
||||||
const [activeTab, setActiveTab] = useState('topics'); // 'topics' or 'face'
|
|
||||||
|
const DiscussedTopics = ({ topics = [], faceAnalystList = [] }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState('topics'); // 'topics' | 'face'
|
||||||
|
const [imageSrcs, setImageSrcs] = useState({}); // { [id]: dataUrl }
|
||||||
|
const [imgErrors, setImgErrors] = useState({}); // { [id]: true }
|
||||||
|
|
||||||
|
// super simple: always expect [{ data: "<base64>" }]
|
||||||
|
const fetchImageSrc = async (id) => {
|
||||||
|
const token =
|
||||||
|
localStorage.getItem('token') ||
|
||||||
|
localStorage.getItem('access_token') ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
const resp = await fetch(
|
||||||
|
`${IMG_BASE}/16f3a63d-0271-4f03-b1fb-14a051c57285/face-analysis-image/${id}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
|
||||||
|
const json = await resp.json();
|
||||||
|
const base64 = json?.[0]?.data;
|
||||||
|
if (!base64) throw new Error('No base64 data');
|
||||||
|
|
||||||
|
// Your backend returns JPEG base64, so just prefix it:
|
||||||
|
return `data:image/jpeg;base64,${base64}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch images when Face tab becomes active
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'face' || faceAnalystList.length === 0) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const nextImages = {};
|
||||||
|
const nextErrors = {};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
faceAnalystList.map(async (item) => {
|
||||||
|
const id = item?.id;
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const src = await fetchImageSrc(id);
|
||||||
|
nextImages[id] = src;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to load image for ${id}`, e);
|
||||||
|
nextErrors[id] = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setImageSrcs(nextImages);
|
||||||
|
setImgErrors(nextErrors);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [activeTab, faceAnalystList]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
@@ -55,15 +122,37 @@ const DiscussedTopics = ({ topics, faceAnalystList }) => {
|
|||||||
<p>No Face Analyst Report</p>
|
<p>No Face Analyst Report</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
faceAnalystList.map((item, idx) => (
|
faceAnalystList.map((item, idx) => {
|
||||||
<div key={idx} className={styles.card}>
|
const id = item?.id;
|
||||||
<div className={styles.cardContent}>
|
const src = id ? imageSrcs[id] : null;
|
||||||
<h3 className={styles.topicName}>{item.name}</h3>
|
const hadError = id ? imgErrors[id] : false;
|
||||||
<p className={styles.description}>{item.description}</p>
|
|
||||||
<p className={styles.phone}>📞 {item.phone_number}</p>
|
return (
|
||||||
|
<div key={idx} className={styles.card}>
|
||||||
|
<div className={styles.cardContent}>
|
||||||
|
<h3 className={styles.topicName}>{item.name}</h3>
|
||||||
|
|
||||||
|
{src && (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={`Face analysis ${item.name || id}`}
|
||||||
|
className={styles.imagePreview}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!src && !hadError && (
|
||||||
|
<div className={styles.imageLoading}>Loading image…</div>
|
||||||
|
)}
|
||||||
|
{hadError && (
|
||||||
|
<div className={styles.imageError}>Couldn’t load image</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className={styles.description}>{item.description}</p>
|
||||||
|
<p className={styles.phone}>📞 {item.phone_number}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -216,3 +216,8 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.imagePreview{
|
||||||
|
border-radius: 15px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
243
src/FollowUps.js
243
src/FollowUps.js
@@ -1,6 +1,67 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import styles from './FollowUps.module.css';
|
import styles from './FollowUps.module.css';
|
||||||
|
|
||||||
|
// Prefix → ikon (bebas ganti jadi SVG/logo sendiri)
|
||||||
|
const PREFIX_ICON = {
|
||||||
|
WGG: 'WHATSAPP', // WhatsApp
|
||||||
|
TGG: 'TELEGRAM', // Telegram
|
||||||
|
WEB: 'WEBSITE', // Web
|
||||||
|
IGG: 'INSTAGRAM', // Instagram
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: parse, normalisasi, dan validasi nomor Indonesia
|
||||||
|
function parseContactInfo(raw) {
|
||||||
|
if (!raw) return { prefix: null, digits: null, display: raw || '', isValid: false };
|
||||||
|
|
||||||
|
let text = String(raw).trim();
|
||||||
|
let prefix = null;
|
||||||
|
|
||||||
|
// Deteksi prefix di awal (dengan/tanpa '-')
|
||||||
|
const m = text.match(/^(WGG|TGG|WEB|IGG)-?/i);
|
||||||
|
if (m) {
|
||||||
|
prefix = m[1].toUpperCase();
|
||||||
|
text = text.slice(m[0].length); // buang prefix dari display
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bersihkan: sisakan angka dan tanda '+'
|
||||||
|
let cleaned = text.replace(/[^\d+]/g, '');
|
||||||
|
|
||||||
|
// Normalisasi: buang '+' untuk proses
|
||||||
|
if (cleaned.startsWith('+')) cleaned = cleaned.slice(1);
|
||||||
|
|
||||||
|
// Jika 08… → ganti ke 62…
|
||||||
|
if (cleaned.startsWith('0')) cleaned = `62${cleaned.slice(1)}`;
|
||||||
|
|
||||||
|
// Hanya digit
|
||||||
|
let digits = cleaned.replace(/\D/g, '');
|
||||||
|
// Setelah menghasilkan `digits` yang hanya angka
|
||||||
|
if (digits.startsWith('6262')) {
|
||||||
|
// buang '62' ekstra di depan
|
||||||
|
digits = digits.slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Juga bereskan kasus '6208...' (orang nulis '62' + nomor lokal '08...')
|
||||||
|
if (digits.startsWith('620')) {
|
||||||
|
digits = '62' + digits.slice(3); // hasilnya '628...'
|
||||||
|
}
|
||||||
|
|
||||||
|
// (opsional) Kalau user pakai format internasional '0062...' → jadikan ke '62...'
|
||||||
|
if (digits.startsWith('00')) {
|
||||||
|
digits = digits.slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pastikan diawali 62
|
||||||
|
if (!digits.startsWith('62') && /^\d+$/.test(digits)) {
|
||||||
|
// Jika bukan 62, biarkan apa adanya (mungkin sudah nomor lokal lain),
|
||||||
|
// tapi validator di bawah akan menandai tidak valid.
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = /^62\d{7,13}$/.test(digits); // 62 + 7–13 digit
|
||||||
|
const display = isValid ? `+${digits}` : (text.trim() || raw);
|
||||||
|
|
||||||
|
return { prefix, digits, display, isValid };
|
||||||
|
}
|
||||||
|
|
||||||
const FollowUps = ({ data: initialData }) => {
|
const FollowUps = ({ data: initialData }) => {
|
||||||
const [data, setData] = useState(initialData);
|
const [data, setData] = useState(initialData);
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
@@ -13,9 +74,7 @@ const FollowUps = ({ data: initialData }) => {
|
|||||||
try {
|
try {
|
||||||
await fetch('https://bot.kediritechnopark.com/webhook/set-follow-up', {
|
await fetch('https://bot.kediritechnopark.com/webhook/set-follow-up', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
isfollowup: user.isfollowup ? 'success' : 'followup'
|
isfollowup: user.isfollowup ? 'success' : 'followup'
|
||||||
@@ -36,8 +95,11 @@ const FollowUps = ({ data: initialData }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Buka WA chat pakai nomor yang sudah dinormalisasi (jika valid)
|
||||||
if (!user.isfollowup) {
|
if (!user.isfollowup) {
|
||||||
window.open(`https://api.whatsapp.com/send?phone=${user.contact_info}`, '_blank');
|
const parsed = parseContactInfo(user.contact_info);
|
||||||
|
const waNumber = parsed.isValid ? parsed.digits : user.contact_info;
|
||||||
|
window.open(`https://api.whatsapp.com/send?phone=${waNumber}`, '_blank');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to set follow-up:', error);
|
console.error('Failed to set follow-up:', error);
|
||||||
@@ -45,31 +107,21 @@ const FollowUps = ({ data: initialData }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Gabungkan data berdasarkan contact_info dan hilangkan note yang sama secara berurutan
|
// Gabungkan data berdasarkan contact_info dan hilangkan note yang sama berurutan
|
||||||
const mergedDataMap = new Map();
|
const mergedDataMap = new Map();
|
||||||
|
|
||||||
data.forEach(user => {
|
data.forEach(user => {
|
||||||
const key = user.contact_info;
|
const key = user.contact_info;
|
||||||
|
|
||||||
if (!mergedDataMap.has(key)) {
|
if (!mergedDataMap.has(key)) {
|
||||||
mergedDataMap.set(key, {
|
mergedDataMap.set(key, {
|
||||||
...user,
|
...user,
|
||||||
notesList: [{
|
notesList: [{ note: user.notes, created_at: user.created_at }]
|
||||||
note: user.notes,
|
|
||||||
created_at: user.created_at
|
|
||||||
}]
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const existing = mergedDataMap.get(key);
|
const existing = mergedDataMap.get(key);
|
||||||
const lastNote = existing.notesList[existing.notesList.length - 1];
|
const lastNote = existing.notesList[existing.notesList.length - 1];
|
||||||
if (!lastNote || lastNote.note !== user.notes) {
|
if (!lastNote || lastNote.note !== user.notes) {
|
||||||
existing.notesList.push({
|
existing.notesList.push({ note: user.notes, created_at: user.created_at });
|
||||||
note: user.notes,
|
|
||||||
created_at: user.created_at
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prioritaskan status tertinggi
|
|
||||||
existing.issuccess = existing.issuccess || user.issuccess;
|
existing.issuccess = existing.issuccess || user.issuccess;
|
||||||
existing.isfollowup = existing.issuccess ? false : (existing.isfollowup || user.isfollowup);
|
existing.isfollowup = existing.issuccess ? false : (existing.isfollowup || user.isfollowup);
|
||||||
}
|
}
|
||||||
@@ -82,14 +134,10 @@ const FollowUps = ({ data: initialData }) => {
|
|||||||
const filteredData = mergedData
|
const filteredData = mergedData
|
||||||
.filter(user => {
|
.filter(user => {
|
||||||
switch (statusFilter) {
|
switch (statusFilter) {
|
||||||
case 'pending':
|
case 'pending': return !user.isfollowup && !user.issuccess;
|
||||||
return !user.isfollowup && !user.issuccess;
|
case 'inProgress': return user.isfollowup && !user.issuccess;
|
||||||
case 'inProgress':
|
case 'success': return user.issuccess;
|
||||||
return user.isfollowup && !user.issuccess;
|
default: return true;
|
||||||
case 'success':
|
|
||||||
return user.issuccess;
|
|
||||||
default:
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter(user => {
|
.filter(user => {
|
||||||
@@ -98,10 +146,11 @@ const FollowUps = ({ data: initialData }) => {
|
|||||||
switch (dateFilter) {
|
switch (dateFilter) {
|
||||||
case 'today':
|
case 'today':
|
||||||
return created.toDateString() === now.toDateString();
|
return created.toDateString() === now.toDateString();
|
||||||
case 'week':
|
case 'week': {
|
||||||
const aWeekAgo = new Date();
|
const aWeekAgo = new Date();
|
||||||
aWeekAgo.setDate(now.getDate() - 7);
|
aWeekAgo.setDate(now.getDate() - 7);
|
||||||
return created >= aWeekAgo;
|
return created >= aWeekAgo;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -119,9 +168,9 @@ const FollowUps = ({ data: initialData }) => {
|
|||||||
<div className={styles.filterGroup}>
|
<div className={styles.filterGroup}>
|
||||||
<div className={styles.filterItem}>
|
<div className={styles.filterItem}>
|
||||||
<label className={styles.filterLabel}>Status</label>
|
<label className={styles.filterLabel}>Status</label>
|
||||||
<select
|
<select
|
||||||
className={styles.filterSelect}
|
className={styles.filterSelect}
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={e => setStatusFilter(e.target.value)}
|
onChange={e => setStatusFilter(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="all">All Statuses</option>
|
<option value="all">All Statuses</option>
|
||||||
@@ -133,9 +182,9 @@ const FollowUps = ({ data: initialData }) => {
|
|||||||
|
|
||||||
<div className={styles.filterItem}>
|
<div className={styles.filterItem}>
|
||||||
<label className={styles.filterLabel}>Period</label>
|
<label className={styles.filterLabel}>Period</label>
|
||||||
<select
|
<select
|
||||||
className={styles.filterSelect}
|
className={styles.filterSelect}
|
||||||
value={dateFilter}
|
value={dateFilter}
|
||||||
onChange={e => setDateFilter(e.target.value)}
|
onChange={e => setDateFilter(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="all">All Time</option>
|
<option value="all">All Time</option>
|
||||||
@@ -146,9 +195,9 @@ const FollowUps = ({ data: initialData }) => {
|
|||||||
|
|
||||||
<div className={styles.filterItem}>
|
<div className={styles.filterItem}>
|
||||||
<label className={styles.filterLabel}>Sort By</label>
|
<label className={styles.filterLabel}>Sort By</label>
|
||||||
<select
|
<select
|
||||||
className={styles.filterSelect}
|
className={styles.filterSelect}
|
||||||
value={sortOrder}
|
value={sortOrder}
|
||||||
onChange={e => setSortOrder(e.target.value)}
|
onChange={e => setSortOrder(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="latest">Latest</option>
|
<option value="latest">Latest</option>
|
||||||
@@ -170,59 +219,87 @@ const FollowUps = ({ data: initialData }) => {
|
|||||||
<p>No data matches the filters</p>
|
<p>No data matches the filters</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredData.map(user => (
|
filteredData.map(user => {
|
||||||
<div key={user.contact_info} className={styles.card}>
|
const parsed = parseContactInfo(user.contact_info);
|
||||||
<div className={styles.cardHeader}>
|
const icon = parsed.prefix ? PREFIX_ICON[parsed.prefix] : '📞';
|
||||||
<h3 className={styles.userName}>{user.name}</h3>
|
const isValid = parsed.isValid;
|
||||||
<span className={styles.statusBadge}>
|
|
||||||
{user.issuccess ? (
|
return (
|
||||||
<span className={styles.badgeSuccess}>✓ Followed Up</span>
|
<div key={user.contact_info} className={styles.card}>
|
||||||
) : user.isfollowup ? (
|
<div className={styles.cardHeader}>
|
||||||
<span className={styles.badgeProgress}>⏳ In Progress</span>
|
<h3 className={styles.userName}>{user.name} DARI {icon}</h3>
|
||||||
) : (
|
<span className={styles.statusBadge}>
|
||||||
<span className={styles.badgePending}>• Pending</span>
|
{user.issuccess ? (
|
||||||
)}
|
<span className={styles.badgeSuccess}>✓ Followed Up</span>
|
||||||
</span>
|
) : user.isfollowup ? (
|
||||||
</div>
|
<span className={styles.badgeProgress}>⏳ In Progress</span>
|
||||||
|
) : (
|
||||||
<div className={styles.cardContent}>
|
<span className={styles.badgePending}>• Pending</span>
|
||||||
<ul className={styles.notesList}>
|
)}
|
||||||
{user.notesList.map((entry, index) => (
|
</span>
|
||||||
<li key={index}>
|
</div>
|
||||||
<strong>{new Date(entry.created_at).toLocaleString('id-ID', {
|
|
||||||
dateStyle: 'medium',
|
<div className={styles.cardContent}>
|
||||||
timeStyle: 'short',
|
<ul className={styles.notesList}>
|
||||||
timeZone: 'Asia/Jakarta'
|
{user.notesList.map((entry, index) => (
|
||||||
})}:</strong> {entry.note}
|
<li key={index}>
|
||||||
</li>
|
<strong>{new Date(entry.created_at).toLocaleString('id-ID', {
|
||||||
))}
|
dateStyle: 'medium',
|
||||||
</ul>
|
timeStyle: 'short',
|
||||||
<div className={styles.contactInfo}>
|
timeZone: 'Asia/Jakarta'
|
||||||
<span className={styles.contactLabel}>Contact:</span>
|
})}:</strong> {entry.note}
|
||||||
<span className={styles.contactValue}>{user.contact_info}</span>
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className={styles.contactInfo}>
|
||||||
|
<span className={styles.contactLabel}>Contact:</span>
|
||||||
|
<span className={styles.contactValue}>
|
||||||
|
<span
|
||||||
|
className={styles.contactPrefix}
|
||||||
|
title={
|
||||||
|
parsed.prefix === 'WGG' ? 'WhatsApp' :
|
||||||
|
parsed.prefix === 'TGG' ? 'Telegram' :
|
||||||
|
parsed.prefix === 'WEB' ? 'Website' :
|
||||||
|
parsed.prefix === 'IGG' ? 'Instagram' : 'Phone'
|
||||||
|
}
|
||||||
|
aria-label="contact-source"
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
{/* Tampilkan HANYA nomor telepon (valid Indonesia) bila ada prefix */}
|
||||||
|
{parsed.prefix
|
||||||
|
? (isValid ? parsed.display : parsed.display) // tetap tampilkan hasil normalisasi
|
||||||
|
: (parsed.display) /* tanpa prefix, tampilkan sesuai normalisasi */}
|
||||||
|
{!isValid && (
|
||||||
|
<span className={styles.invalidHint} title="Nomor tidak terdeteksi sebagai +62">
|
||||||
|
{' '}• cek format
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.cardActions}>
|
||||||
|
<button
|
||||||
|
className={`${styles.actionBtn} ${
|
||||||
|
user.issuccess
|
||||||
|
? styles.btnSuccess
|
||||||
|
: user.isfollowup
|
||||||
|
? styles.btnComplete
|
||||||
|
: styles.btnPrimary
|
||||||
|
}`}
|
||||||
|
onClick={(e) => handleFollowUp(e, user)}
|
||||||
|
>
|
||||||
|
{user.issuccess
|
||||||
|
? '✓ Follow-up Success'
|
||||||
|
: user.isfollowup
|
||||||
|
? '✓ Mark as Complete'
|
||||||
|
: '💬 Chat on WhatsApp'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
<div className={styles.cardActions}>
|
})
|
||||||
<button
|
|
||||||
className={`${styles.actionBtn} ${
|
|
||||||
user.issuccess
|
|
||||||
? styles.btnSuccess
|
|
||||||
: user.isfollowup
|
|
||||||
? styles.btnComplete
|
|
||||||
: styles.btnPrimary
|
|
||||||
}`}
|
|
||||||
onClick={(e) => handleFollowUp(e, user)}
|
|
||||||
>
|
|
||||||
{user.issuccess
|
|
||||||
? '✓ Follow-up Success'
|
|
||||||
: user.isfollowup
|
|
||||||
? '✓ Mark as Complete'
|
|
||||||
: '💬 Chat on WhatsApp'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user