This commit is contained in:
Vassshhh
2025-08-25 11:31:24 +07:00
parent c1a9f37888
commit 2b1345d046
3 changed files with 265 additions and 94 deletions

View File

@@ -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}>Couldnt 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>

View File

@@ -216,3 +216,8 @@
font-size: 0.85rem; font-size: 0.85rem;
color: #666; color: #666;
} }
.imagePreview{
border-radius: 15px;
width: 100%;
}

View File

@@ -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 + 713 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>