ok
This commit is contained in:
@@ -1,9 +1,76 @@
|
||||
// DiscussedTopics.js
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styles from './DiscussedTopics.module.css';
|
||||
|
||||
const DiscussedTopics = ({ topics, faceAnalystList }) => {
|
||||
const [activeTab, setActiveTab] = useState('topics'); // 'topics' or 'face'
|
||||
const IMG_BASE = 'https://bot.kediritechnopark.com/webhook';
|
||||
|
||||
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 (
|
||||
<div className={styles.container}>
|
||||
@@ -55,15 +122,37 @@ const DiscussedTopics = ({ topics, faceAnalystList }) => {
|
||||
<p>No Face Analyst Report</p>
|
||||
</div>
|
||||
) : (
|
||||
faceAnalystList.map((item, idx) => (
|
||||
<div key={idx} className={styles.card}>
|
||||
<div className={styles.cardContent}>
|
||||
<h3 className={styles.topicName}>{item.name}</h3>
|
||||
<p className={styles.description}>{item.description}</p>
|
||||
<p className={styles.phone}>📞 {item.phone_number}</p>
|
||||
faceAnalystList.map((item, idx) => {
|
||||
const id = item?.id;
|
||||
const src = id ? imageSrcs[id] : null;
|
||||
const hadError = id ? imgErrors[id] : false;
|
||||
|
||||
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>
|
||||
|
||||
@@ -216,3 +216,8 @@
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.imagePreview{
|
||||
border-radius: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
229
src/FollowUps.js
229
src/FollowUps.js
@@ -1,6 +1,67 @@
|
||||
import React, { useState } from 'react';
|
||||
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 [data, setData] = useState(initialData);
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
@@ -13,9 +74,7 @@ const FollowUps = ({ data: initialData }) => {
|
||||
try {
|
||||
await fetch('https://bot.kediritechnopark.com/webhook/set-follow-up', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: user.id,
|
||||
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) {
|
||||
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) {
|
||||
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();
|
||||
|
||||
data.forEach(user => {
|
||||
const key = user.contact_info;
|
||||
|
||||
if (!mergedDataMap.has(key)) {
|
||||
mergedDataMap.set(key, {
|
||||
...user,
|
||||
notesList: [{
|
||||
note: user.notes,
|
||||
created_at: user.created_at
|
||||
}]
|
||||
notesList: [{ note: user.notes, created_at: user.created_at }]
|
||||
});
|
||||
} else {
|
||||
const existing = mergedDataMap.get(key);
|
||||
const lastNote = existing.notesList[existing.notesList.length - 1];
|
||||
if (!lastNote || lastNote.note !== user.notes) {
|
||||
existing.notesList.push({
|
||||
note: user.notes,
|
||||
created_at: user.created_at
|
||||
});
|
||||
existing.notesList.push({ note: user.notes, created_at: user.created_at });
|
||||
}
|
||||
|
||||
// Prioritaskan status tertinggi
|
||||
existing.issuccess = existing.issuccess || user.issuccess;
|
||||
existing.isfollowup = existing.issuccess ? false : (existing.isfollowup || user.isfollowup);
|
||||
}
|
||||
@@ -82,14 +134,10 @@ const FollowUps = ({ data: initialData }) => {
|
||||
const filteredData = mergedData
|
||||
.filter(user => {
|
||||
switch (statusFilter) {
|
||||
case 'pending':
|
||||
return !user.isfollowup && !user.issuccess;
|
||||
case 'inProgress':
|
||||
return user.isfollowup && !user.issuccess;
|
||||
case 'success':
|
||||
return user.issuccess;
|
||||
default:
|
||||
return true;
|
||||
case 'pending': return !user.isfollowup && !user.issuccess;
|
||||
case 'inProgress': return user.isfollowup && !user.issuccess;
|
||||
case 'success': return user.issuccess;
|
||||
default: return true;
|
||||
}
|
||||
})
|
||||
.filter(user => {
|
||||
@@ -98,10 +146,11 @@ const FollowUps = ({ data: initialData }) => {
|
||||
switch (dateFilter) {
|
||||
case 'today':
|
||||
return created.toDateString() === now.toDateString();
|
||||
case 'week':
|
||||
case 'week': {
|
||||
const aWeekAgo = new Date();
|
||||
aWeekAgo.setDate(now.getDate() - 7);
|
||||
return created >= aWeekAgo;
|
||||
}
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
@@ -170,59 +219,87 @@ const FollowUps = ({ data: initialData }) => {
|
||||
<p>No data matches the filters</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredData.map(user => (
|
||||
<div key={user.contact_info} className={styles.card}>
|
||||
<div className={styles.cardHeader}>
|
||||
<h3 className={styles.userName}>{user.name}</h3>
|
||||
<span className={styles.statusBadge}>
|
||||
{user.issuccess ? (
|
||||
<span className={styles.badgeSuccess}>✓ Followed Up</span>
|
||||
) : user.isfollowup ? (
|
||||
<span className={styles.badgeProgress}>⏳ In Progress</span>
|
||||
) : (
|
||||
<span className={styles.badgePending}>• Pending</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
filteredData.map(user => {
|
||||
const parsed = parseContactInfo(user.contact_info);
|
||||
const icon = parsed.prefix ? PREFIX_ICON[parsed.prefix] : '📞';
|
||||
const isValid = parsed.isValid;
|
||||
|
||||
<div className={styles.cardContent}>
|
||||
<ul className={styles.notesList}>
|
||||
{user.notesList.map((entry, index) => (
|
||||
<li key={index}>
|
||||
<strong>{new Date(entry.created_at).toLocaleString('id-ID', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
timeZone: 'Asia/Jakarta'
|
||||
})}:</strong> {entry.note}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.contactInfo}>
|
||||
<span className={styles.contactLabel}>Contact:</span>
|
||||
<span className={styles.contactValue}>{user.contact_info}</span>
|
||||
return (
|
||||
<div key={user.contact_info} className={styles.card}>
|
||||
<div className={styles.cardHeader}>
|
||||
<h3 className={styles.userName}>{user.name} DARI {icon}</h3>
|
||||
<span className={styles.statusBadge}>
|
||||
{user.issuccess ? (
|
||||
<span className={styles.badgeSuccess}>✓ Followed Up</span>
|
||||
) : user.isfollowup ? (
|
||||
<span className={styles.badgeProgress}>⏳ In Progress</span>
|
||||
) : (
|
||||
<span className={styles.badgePending}>• Pending</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.cardContent}>
|
||||
<ul className={styles.notesList}>
|
||||
{user.notesList.map((entry, index) => (
|
||||
<li key={index}>
|
||||
<strong>{new Date(entry.created_at).toLocaleString('id-ID', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
timeZone: 'Asia/Jakarta'
|
||||
})}:</strong> {entry.note}
|
||||
</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 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>
|
||||
|
||||
Reference in New Issue
Block a user