ok
This commit is contained in:
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import Modal from './Modal';
|
||||
import Conversations from './Conversations';
|
||||
import DiscussedTopics from './DiscussedTopics';
|
||||
import StatCard from './StatCard'
|
||||
|
||||
import FollowUps from './FollowUps';
|
||||
|
||||
@@ -36,17 +37,63 @@ const Dashboard = () => {
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleFiles = (files) => {
|
||||
const newFiles = files.filter(file => {
|
||||
// Hindari duplikat berdasarkan nama (atau bisa pakai hash/md5 jika perlu)
|
||||
return !selectedFiles.some(f => f.name === file.name);
|
||||
const handleFiles = async (files) => {
|
||||
const filteredFiles = [];
|
||||
|
||||
for (const file of files) {
|
||||
const lowerName = file.name.toLowerCase();
|
||||
const nameWithoutExt = lowerName.replace(/\.[^/.]+$/, '');
|
||||
|
||||
// 1️⃣ Cegah duplikat dari file yang sudah dipilih sebelumnya
|
||||
const alreadySelected = selectedFiles.some(f =>
|
||||
f.name.toLowerCase() === file.name.toLowerCase()
|
||||
);
|
||||
if (alreadySelected) continue;
|
||||
|
||||
// 2️⃣ Cari file server yang mirip (berisi / mengandung)
|
||||
const similarFile = fileList.find(f => {
|
||||
const serverName = f.json.Key.toLowerCase();
|
||||
const serverNameWithoutExt = serverName.replace(/\.[^/.]+$/, '');
|
||||
|
||||
return (
|
||||
serverName.includes(lowerName) ||
|
||||
lowerName.includes(serverName) ||
|
||||
serverNameWithoutExt.includes(nameWithoutExt) ||
|
||||
nameWithoutExt.includes(serverNameWithoutExt)
|
||||
);
|
||||
});
|
||||
|
||||
setSelectedFiles((prev) => [...prev, ...newFiles]);
|
||||
if (similarFile) {
|
||||
const confirmOverwrite = window.confirm(
|
||||
`File "${file.name}" mirip atau mengandung "${similarFile.json.Key}" di server.\nIngin menimpa file tersebut?`
|
||||
);
|
||||
|
||||
if (confirmOverwrite) {
|
||||
// Ganti nama agar ditimpa
|
||||
Object.defineProperty(file, 'name', {
|
||||
writable: true,
|
||||
value: similarFile.json.Key,
|
||||
});
|
||||
filteredFiles.push(file); // tetap tambahkan
|
||||
} else {
|
||||
// Tidak ditimpa, tetap pakai nama asli
|
||||
filteredFiles.push(file); // tambahkan tanpa modifikasi nama
|
||||
}
|
||||
} else {
|
||||
// Tidak ada kemiripan, langsung tambahkan
|
||||
filteredFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredFiles.length > 0) {
|
||||
setSelectedFiles(prev => [...prev, ...filteredFiles]);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
@@ -491,10 +538,7 @@ const handleBatchDelete = async () => {
|
||||
<h2>{stats.botMessages}</h2>
|
||||
<p>AI RESPONSE</p>
|
||||
</div>
|
||||
<div className={styles.statCard} onClick={() => setModalContent(<FollowUps data={followUps} />)}>
|
||||
<h2>{followUps.length}</h2>
|
||||
<p>BOOKING REQUEST</p>
|
||||
</div>
|
||||
<StatCard followUps={followUps} setModalContent={setModalContent} />
|
||||
<div className={styles.statCard} onClick={openTopicsModal}>
|
||||
<h2 style={{ fontSize: '17px' }}>{discussedTopics[0]?.topic}</h2>
|
||||
<p>Top topic</p>
|
||||
@@ -585,7 +629,7 @@ const handleBatchDelete = async () => {
|
||||
|
||||
{selectedFiles.length > 0 &&
|
||||
selectedFiles.map((file, index) => (
|
||||
<div>
|
||||
<div key={index}>
|
||||
<div key={index} className={styles.fileInfo}>
|
||||
<strong>{file.name}</strong>
|
||||
</div>
|
||||
@@ -626,7 +670,7 @@ const handleBatchDelete = async () => {
|
||||
|
||||
|
||||
<div className={styles.footer}>
|
||||
© 2025 Kediri Technopark
|
||||
© 2025 Dermalounge
|
||||
</div>
|
||||
|
||||
{modalContent && <Modal onClose={() => setModalContent(null)}>{modalContent}</Modal>}
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
.fileUpload {
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
color: #333;
|
||||
background: #2bb438;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
|
||||
// DiscussedTopics.js
|
||||
import React from 'react';
|
||||
import styles from './DiscussedTopics.module.css';
|
||||
|
||||
const DiscussedTopics = ({ topics }) => {
|
||||
return (
|
||||
<div>
|
||||
<h2>Top Topic</h2>
|
||||
<ul>
|
||||
{topics.map((topic, idx) => (
|
||||
<li key={idx}><strong>{topic.topic}</strong> - {topic.count} x</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.title}>Top Topic</h2>
|
||||
<div className={styles.resultCount}>
|
||||
{topics.length} topik
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.grid}>
|
||||
{topics.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyIcon}>💬</div>
|
||||
<p>Discussed Topic Is Empty</p>
|
||||
</div>
|
||||
) : (
|
||||
topics.map((topic, idx) => (
|
||||
<div key={idx} className={styles.card}>
|
||||
<div className={styles.cardContent}>
|
||||
<h3 className={styles.topicName}>{topic.topic}</h3>
|
||||
<div className={styles.countBadge}>
|
||||
<span className={styles.countValue}>{topic.count}</span>
|
||||
<span className={styles.countLabel}>times</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
187
src/DiscussedTopics.module.css
Normal file
187
src/DiscussedTopics.module.css
Normal file
@@ -0,0 +1,187 @@
|
||||
/* DiscussedTopics.module.css */
|
||||
.container {
|
||||
background-color: #f8fafc;
|
||||
padding: 20px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.resultCount {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
padding: 8px 12px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Cards Grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.topicName {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.countBadge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #0b7366;
|
||||
}
|
||||
|
||||
.countValue {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #0b7366;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.countLabel {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.emptyState {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.emptyState p {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 16px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.topicName {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.countValue {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.topicName {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.countBadge {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.countValue {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.countLabel {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
194
src/FollowUps.js
194
src/FollowUps.js
@@ -1,36 +1,194 @@
|
||||
import React from 'react';
|
||||
// FollowUps.js
|
||||
import React, { useState } from 'react';
|
||||
import styles from './FollowUps.module.css';
|
||||
|
||||
const FollowUps = ({ data }) => {
|
||||
const FollowUps = ({ data: initialData }) => {
|
||||
const [data, setData] = useState(initialData);
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [dateFilter, setDateFilter] = useState('all');
|
||||
const [sortOrder, setSortOrder] = useState('latest');
|
||||
|
||||
const handleFollowUp = async (e, user) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
await fetch('https://bot.kediritechnopark.com/webhook/set-follow-up', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: user.id,
|
||||
isfollowup: user.isfollowup ? 'success' : 'followup'
|
||||
})
|
||||
});
|
||||
|
||||
if (user.isfollowup) {
|
||||
setData(prev =>
|
||||
prev.map(u =>
|
||||
u.id === user.id ? { ...u, isfollowup: false, issuccess: true } : u
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setData(prev =>
|
||||
prev.map(u =>
|
||||
u.id === user.id ? { ...u, isfollowup: true } : u
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!user.isfollowup) {
|
||||
window.open(`https://api.whatsapp.com/send?phone=${user.contact_info}`, '_blank');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set follow-up:', error);
|
||||
alert('Failed to send follow-up status.');
|
||||
}
|
||||
};
|
||||
|
||||
// Filter & Sort
|
||||
const now = new Date();
|
||||
const filteredData = data
|
||||
.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;
|
||||
}
|
||||
})
|
||||
.filter(user => {
|
||||
const created = new Date(user.created_at);
|
||||
switch (dateFilter) {
|
||||
case 'today':
|
||||
return created.toDateString() === now.toDateString();
|
||||
case 'week':
|
||||
const aWeekAgo = new Date();
|
||||
aWeekAgo.setDate(now.getDate() - 7);
|
||||
return created >= aWeekAgo;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dateA = new Date(a.created_at);
|
||||
const dateB = new Date(b.created_at);
|
||||
return sortOrder === 'latest' ? dateB - dateA : dateA - dateB;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Filter Controls */}
|
||||
<div className={styles.filterSection}>
|
||||
<div className={styles.filterGroup}>
|
||||
<div className={styles.filterItem}>
|
||||
<label className={styles.filterLabel}>Status</label>
|
||||
<select
|
||||
className={styles.filterSelect}
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="inProgress">In Progress</option>
|
||||
<option value="success">Followed Up</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterItem}>
|
||||
<label className={styles.filterLabel}>Period</label>
|
||||
<select
|
||||
className={styles.filterSelect}
|
||||
value={dateFilter}
|
||||
onChange={e => setDateFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Time</option>
|
||||
<option value="today">Today</option>
|
||||
<option value="week">Last 7 Days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterItem}>
|
||||
<label className={styles.filterLabel}>Sort By</label>
|
||||
<select
|
||||
className={styles.filterSelect}
|
||||
value={sortOrder}
|
||||
onChange={e => setSortOrder(e.target.value)}
|
||||
>
|
||||
<option value="latest">Latest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.resultCount}>
|
||||
{filteredData.length} of {data.length} records
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
<div className={styles.grid}>
|
||||
{data.map(user => (
|
||||
{filteredData.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyIcon}>📋</div>
|
||||
<p>No data matches the filters</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredData.map(user => (
|
||||
<div key={user.id} className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<h3>{user.name}</h3>
|
||||
<span className={styles.date}>
|
||||
<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>
|
||||
|
||||
<div className={styles.cardContent}>
|
||||
<p className={styles.notes}>{user.notes}</p>
|
||||
<div className={styles.contactInfo}>
|
||||
<span className={styles.contactLabel}>Contact:</span>
|
||||
<span className={styles.contactValue}>{user.contact_info}</span>
|
||||
</div>
|
||||
<div className={styles.dateInfo}>
|
||||
{new Date(user.created_at).toLocaleString('id-ID', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
timeZone: 'Asia/Jakarta'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className={styles.notes}>{user.notes}</p>
|
||||
<div className={styles.footer}>
|
||||
<span className={styles.contact}>{user.contact_info}</span>
|
||||
<a
|
||||
className={styles.chatBtn}
|
||||
href={`https://api.whatsapp.com/send?phone=${user.contact_info}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
</div>
|
||||
|
||||
<div className={styles.cardActions}>
|
||||
<button
|
||||
className={`${styles.actionBtn} ${
|
||||
user.issuccess
|
||||
? styles.btnSuccess
|
||||
: user.isfollowup
|
||||
? styles.btnComplete
|
||||
: styles.btnPrimary
|
||||
}`}
|
||||
onClick={(e) => handleFollowUp(e, user)}
|
||||
>
|
||||
Chat WhatsApp
|
||||
</a>
|
||||
{user.issuccess
|
||||
? '✓ Follow-up Success'
|
||||
: user.isfollowup
|
||||
? '✓ Mark as Complete'
|
||||
: '💬 Chat on WhatsApp'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,110 +1,316 @@
|
||||
/* FollowUps.module.css */
|
||||
.container {
|
||||
background-color: #f7f9fa;
|
||||
font-family: 'Amazon Ember', sans-serif;
|
||||
background-color: #f8fafc;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #16191f;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
/* Filter Section */
|
||||
.filterSection {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.filterGroup {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.filterItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
padding: 10px 12px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-position: right 8px center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 16px;
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.filterSelect:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.filterSelect:hover {
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.resultCount {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
padding: 8px 12px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Cards Grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
transition: box-shadow 0.2s ease;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.header {
|
||||
.cardHeader {
|
||||
padding: 20px 20px 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header h3 {
|
||||
.userName {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
color: #232f3e;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.date {
|
||||
.statusBadge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badgeSuccess {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badgeProgress {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badgePending {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
padding: 12px 20px 20px;
|
||||
}
|
||||
|
||||
.notes {
|
||||
margin: 12px 0;
|
||||
color: #374151;
|
||||
color: #4b5563;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 16px 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.footer {
|
||||
.contactInfo {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.contact {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.chatBtn {
|
||||
background-color: #25d366;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
.contactLabel {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chatBtn:hover {
|
||||
background-color: #1da851;
|
||||
.contactValue {
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.dateInfo {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
padding: 8px 12px;
|
||||
background: #f9fafb;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btnPrimary {
|
||||
background: #25d366;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btnPrimary:hover {
|
||||
background: #1da851;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btnComplete {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btnComplete:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btnSuccess {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.emptyState {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.emptyState p {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.filterSection {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.filterGroup {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filterItem {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
padding: 16px 16px 10px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
padding: 10px 16px 16px;
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.title {
|
||||
font-size: 18px;
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 12px;
|
||||
.userName {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.notes {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chatBtn {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
.statusBadge span {
|
||||
font-size: 11px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ const Login = () => {
|
||||
</button>
|
||||
</form>
|
||||
<div className={styles.footer}>
|
||||
© 2025 Kediri Technopark
|
||||
© 2025 Dermalounge
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,10 +16,9 @@
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
max-width: 700px;
|
||||
width: 70%;
|
||||
width: 85%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -5,16 +5,25 @@ export default function NotificationPrompt({ onAllow, onDismiss }) {
|
||||
return (
|
||||
<div className={styles.backdrop}>
|
||||
<div className={styles.modal}>
|
||||
<h2 className={styles.title}>Enable Notifications</h2>
|
||||
<div className={styles.iconWrapper}>
|
||||
<div className={styles.notificationIcon}>🔔</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<h2 className={styles.title}>Aktifkan Notifikasi</h2>
|
||||
<p className={styles.description}>
|
||||
Stay up to date with important updates and alerts. Enable push notifications to never miss a thing.
|
||||
Tetap terhubung dengan update penting dan peringatan terbaru.
|
||||
Aktifkan notifikasi push agar tidak ketinggalan informasi penting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button onClick={onAllow} className={styles.primaryButton}>
|
||||
Enable Notifications
|
||||
<span className={styles.buttonIcon}>✓</span>
|
||||
Aktifkan Notifikasi
|
||||
</button>
|
||||
<button onClick={onDismiss} className={styles.secondaryButton}>
|
||||
Maybe Later
|
||||
Nanti Saja
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,35 +1,67 @@
|
||||
/* NotificationPrompt.module.css */
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.45);
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
max-width: 420px;
|
||||
width: 90%;
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid #e2e8f0;
|
||||
text-align: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.notificationIcon {
|
||||
font-size: 48px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 50%;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 16px;
|
||||
font-size: 22px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 24px;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #4b5563;
|
||||
line-height: 1.5;
|
||||
line-height: 1.6;
|
||||
max-width: 340px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -39,30 +71,129 @@
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
background-color: #0073bb;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
padding: 12px 20px;
|
||||
padding: 14px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 15px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.primaryButton:hover {
|
||||
background-color: #005a99;
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.primaryButton:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.buttonIcon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
background-color: transparent;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 12px 20px;
|
||||
border-radius: 6px;
|
||||
color: #6b7280;
|
||||
border: 2px solid #e2e8f0;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.secondaryButton:hover {
|
||||
background-color: #f3f4f6;
|
||||
background-color: #f8fafc;
|
||||
border-color: #cbd5e1;
|
||||
color: #374151;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.secondaryButton:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 480px) {
|
||||
.modal {
|
||||
padding: 24px 20px;
|
||||
margin: 16px;
|
||||
width: calc(100% - 32px);
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.notificationIcon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.primaryButton,
|
||||
.secondaryButton {
|
||||
padding: 12px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 320px) {
|
||||
.modal {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
48
src/StatCard.js
Normal file
48
src/StatCard.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styles from './StatCard.module.css';
|
||||
import FollowUps from './FollowUps';
|
||||
|
||||
const StatCard = ({ followUps, setModalContent }) => {
|
||||
const [activeIndex, setActiveIndex] = useState(0); // 0 = booking request, 1 = sukses
|
||||
const [direction, setDirection] = useState('right'); // for animation direction
|
||||
|
||||
const views = [
|
||||
{
|
||||
label: 'BOOKING REQUEST',
|
||||
data: followUps.filter(u => !u.isfollowup && !u.issuccess),
|
||||
},
|
||||
{
|
||||
label: 'FOLLOWED UP',
|
||||
data: followUps.filter(u => u.issuccess),
|
||||
}
|
||||
];
|
||||
|
||||
// Swipe timer
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setDirection(prev => (prev === 'right' ? 'left' : 'right'));
|
||||
setActiveIndex(prev => 1 - prev);
|
||||
}, 4000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleClick = () => {
|
||||
setModalContent(<FollowUps data={followUps} />);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.statCard} onClick={handleClick}>
|
||||
<div
|
||||
key={activeIndex} // re-render to trigger animation
|
||||
className={`${styles.cardContent} ${
|
||||
direction === 'right' ? styles.slideInRight : styles.slideInLeft
|
||||
}`}
|
||||
>
|
||||
<h2>{views[activeIndex].data.length}</h2>
|
||||
<p>{views[activeIndex].label}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatCard;
|
||||
57
src/StatCard.module.css
Normal file
57
src/StatCard.module.css
Normal file
@@ -0,0 +1,57 @@
|
||||
|
||||
.statCard {
|
||||
overflow: hidden;
|
||||
background: #ece5dd;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.statCard h2 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
color: #075e54;
|
||||
}
|
||||
|
||||
.statCard p {
|
||||
margin: 5px 0 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.cardContent {
|
||||
position: relative;
|
||||
animation-duration: 0.6s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(30%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.slideInRight {
|
||||
animation-name: slideInRight;
|
||||
}
|
||||
|
||||
.slideInLeft {
|
||||
animation-name: slideInLeft;
|
||||
}
|
||||
Reference in New Issue
Block a user