This commit is contained in:
Vassshhh
2025-07-02 17:32:13 +07:00
parent ac212226b3
commit fe7d67a2d8
12 changed files with 1094 additions and 234 deletions

View File

@@ -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,14 +37,60 @@ 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]);
}
};
@@ -364,89 +411,89 @@ const Dashboard = () => {
}
}
};
const handleBatchUpload = async () => {
const token = localStorage.getItem('token');
const newFiles = [];
const handleBatchUpload = async () => {
const token = localStorage.getItem('token');
const newFiles = [];
for (const file of selectedFiles) {
const formData = new FormData();
formData.append('file', file);
for (const file of selectedFiles) {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('https://bot.kediritechnopark.com/webhook/files/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
if (response.ok) {
newFiles.push({
json: {
Key: file.name,
LastModified: new Date().toISOString(),
Size: file.size,
StorageClass: 'STANDARD'
}
const response = await fetch('https://bot.kediritechnopark.com/webhook/files/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
} else {
console.error(`Upload gagal untuk file ${file.name}`);
}
}
// ✅ Set fileList sekaligus
setFileList((prev) => [...prev, ...newFiles]);
alert('Upload selesai');
setSelectedFiles([]);
};
const handleBatchDelete = async () => {
if (!window.confirm(`Yakin ingin menghapus ${selectedKeys.length} file?`)) return;
const token = localStorage.getItem('token');
const successKeys = [];
const failedKeys = [];
for (const key of selectedKeys) {
try {
const response = await fetch(
`https://bot.kediritechnopark.com/webhook/files/delete?key=${encodeURIComponent(key)}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
}
);
if (response.ok) {
successKeys.push(key);
newFiles.push({
json: {
Key: file.name,
LastModified: new Date().toISOString(),
Size: file.size,
StorageClass: 'STANDARD'
}
});
} else {
console.error(`Upload gagal untuk file ${file.name}`);
}
}
// ✅ Set fileList sekaligus
setFileList((prev) => [...prev, ...newFiles]);
alert('Upload selesai');
setSelectedFiles([]);
};
const handleBatchDelete = async () => {
if (!window.confirm(`Yakin ingin menghapus ${selectedKeys.length} file?`)) return;
const token = localStorage.getItem('token');
const successKeys = [];
const failedKeys = [];
for (const key of selectedKeys) {
try {
const response = await fetch(
`https://bot.kediritechnopark.com/webhook/files/delete?key=${encodeURIComponent(key)}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
}
);
if (response.ok) {
successKeys.push(key);
} else {
failedKeys.push(key);
}
} catch (err) {
console.error(`Gagal menghapus ${key}`, err);
failedKeys.push(key);
}
} catch (err) {
console.error(`Gagal menghapus ${key}`, err);
failedKeys.push(key);
}
}
// ✅ Update fileList sekaligus
setFileList((prev) =>
prev.filter((file) => !successKeys.includes(file.json.Key))
);
// ✅ Update fileList sekaligus
setFileList((prev) =>
prev.filter((file) => !successKeys.includes(file.json.Key))
);
// ✅ Kosongkan selected
setSelectedKeys([]);
// ✅ Kosongkan selected
setSelectedKeys([]);
// ✅ Beri feedback ke user
if (failedKeys.length === 0) {
alert('File berhasil dihapus.');
} else {
alert(`Sebagian gagal dihapus:\n${failedKeys.join('\n')}`);
}
};
// ✅ Beri feedback ke user
if (failedKeys.length === 0) {
alert('File berhasil dihapus.');
} else {
alert(`Sebagian gagal dihapus:\n${failedKeys.join('\n')}`);
}
};
// ⬇️ Jika masih loading, tampilkan full white screen
@@ -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>
@@ -581,33 +625,33 @@ const handleBatchDelete = async () => {
<p className={styles.mobileText} onClick={() => document.getElementById("fileInput").click()}>Click to upload</p>
<div>
{selectedFiles.length > 0 &&
selectedFiles.map((file, index) => (
<div>
<div key={index} className={styles.fileInfo}>
<strong>{file.name}</strong>
</div>
<div
className={styles.fileInfoClose}
onClick={() =>
setSelectedFiles((prev) => prev.filter((_, i) => i !== index))
}
>
X
</div>
</div>
))}
<div>
{selectedFiles.length > 0 &&
<div>
<div onClick={()=>handleBatchUpload()} className={styles.fileUpload}>
<strong>Upload</strong>
</div>
</div>
}
{selectedFiles.length > 0 &&
selectedFiles.map((file, index) => (
<div key={index}>
<div key={index} className={styles.fileInfo}>
<strong>{file.name}</strong>
</div>
<div
className={styles.fileInfoClose}
onClick={() =>
setSelectedFiles((prev) => prev.filter((_, i) => i !== index))
}
>
X
</div>
</div>
))}
{selectedFiles.length > 0 &&
<div>
<div onClick={() => handleBatchUpload()} className={styles.fileUpload}>
<strong>Upload</strong>
</div>
</div>
}
</div>
<input
id="fileInput"
type="file"
@@ -626,7 +670,7 @@ const handleBatchDelete = async () => {
<div className={styles.footer}>
&copy; 2025 Kediri Technopark
&copy; 2025 Dermalounge
</div>
{modalContent && <Modal onClose={() => setModalContent(null)}>{modalContent}</Modal>}

View File

@@ -137,7 +137,7 @@
.fileUpload {
margin-top: 16px;
font-size: 14px;
color: white;
color: #333;
background: #2bb438;
padding: 8px 12px;
border-radius: 8px;

View File

@@ -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>
);
};

View 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;
}
}

View File

@@ -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}>
<div className={styles.grid}>
{data.map(user => (
<div key={user.id} className={styles.card}>
<div className={styles.header}>
<h3>{user.name}</h3>
<span className={styles.date}>
{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"
>
Chat WhatsApp
</a>
</div>
{/* 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}>
{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.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'
})}
</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>
);

View File

@@ -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;
}
.card {
.container {
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;
}
}
}

View File

@@ -69,7 +69,7 @@ const Login = () => {
</button>
</form>
<div className={styles.footer}>
&copy; 2025 Kediri Technopark
&copy; 2025 Dermalounge
</div>
</div>
</div>

View File

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

View File

@@ -5,19 +5,28 @@ export default function NotificationPrompt({ onAllow, onDismiss }) {
return (
<div className={styles.backdrop}>
<div className={styles.modal}>
<h2 className={styles.title}>Enable Notifications</h2>
<p className={styles.description}>
Stay up to date with important updates and alerts. Enable push notifications to never miss a thing.
</p>
<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}>
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>
</div>
);
}
}

View File

@@ -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
View 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
View 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;
}