feedbackmember+detail+review

This commit is contained in:
bagusajisaputroo
2025-04-24 16:56:38 +07:00
parent fdfb603c54
commit bb5db5019d
5 changed files with 568 additions and 263 deletions

View File

@ -0,0 +1,189 @@
import { useState, useEffect } from 'react';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { useCallApi } from '@/hooks';
import { apiConfig } from '@/config/api.config';
import { getAuth } from '@/auth';
import { useManageFeedbackContext } from '../hooks/useManageFeedbackMemberContext';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { X } from 'lucide-react';
const API_URL = apiConfig.service_feedback;
// Define status types
type StatusCode = 'W' | 'N' | 'Y';
// Status display mapping for Select component
const statusDisplayMap: Record<StatusCode, string> = {
W: 'Waiting Follow Up',
N: 'Rejected',
Y: 'Accepted'
};
const EditDialog = () => {
const [loading, setLoading] = useState(false);
const { PutData, GetData } = useCallApi();
const { handleEditDialog, showEditDialog, selectedFeedback, handleDetailDialog, refreshData } =
useManageFeedbackContext();
const [reviewNotes, setReviewNotes] = useState('');
const [status, setStatus] = useState<StatusCode>('Y'); // Default to Accepted
useEffect(() => {
if (showEditDialog && selectedFeedback) {
fetchFeedbackDetail();
}
}, [showEditDialog, selectedFeedback]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const username = getAuth()?.user.username;
const payload = {
review_by: username,
review_notes: reviewNotes,
status: status
};
const response = await PutData(`${API_URL}/feedback/update/${selectedFeedback}`, payload);
if (response?.status) {
toast.success('Feedback reviewed successfully');
// Close the edit dialog
handleEditDialog(false, null);
// First close the detail dialog to reset its state
handleDetailDialog(false, null);
// Refresh the main data grid first
refreshData();
// Wait a tiny bit then reopen the detail with refreshed data
setTimeout(() => {
handleDetailDialog(true, selectedFeedback);
}, 300);
} else {
toast.error(response?.message || 'Failed to update feedback');
}
} catch (error) {
console.error('Error updating feedback:', error);
toast.error('An error occurred while updating feedback');
} finally {
setLoading(false);
}
};
const fetchFeedbackDetail = async () => {
if (!selectedFeedback) return;
try {
setLoading(true);
const response = await GetData(`${API_URL}/feedback/detail/${selectedFeedback}`, {
id: selectedFeedback
});
if (!response || !response.data) {
console.error('Invalid response format');
return;
}
const data = response.data;
console.log('Fetched data:', data);
setReviewNotes(data.review_notes || '');
const currentStatus = (data.status as StatusCode) || 'Y';
setStatus(currentStatus);
} catch (error) {
console.error('Error fetching feedback details:', error);
} finally {
setLoading(false);
}
};
return (
<Dialog open={showEditDialog} onOpenChange={(open) => handleEditDialog(open, null)}>
<DialogContent className="p-0 w-full sm:max-w-2xl bg-white rounded-lg overflow-hidden">
<div className="relative">
<div className="p-6 flex items-center justify-between border-b">
<div>
<h2 className="text-2xl font-bold text-gray-900">Review Feedback</h2>
<p className="text-sm text-gray-500 mt-1">Review and update feedback status</p>
</div>
<button
className="absolute right-6 top-6 text-gray-400 hover:text-gray-600"
onClick={() => handleEditDialog(false, null)}
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6">
<div className="space-y-6">
<div className="space-y-4">
<label className="block text-gray-700 text-lg">
Review Notes <span className="text-red-500">*</span>
</label>
<textarea
className="w-full min-h-48 p-4 text-gray-700 bg-white border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your review notes here..."
value={reviewNotes}
onChange={(e) => setReviewNotes(e.target.value)}
required
/>
</div>
<div className="space-y-4">
<label className="block text-gray-700 text-lg">
Status <span className="text-red-500">*</span>
</label>
<Select
value={status}
onValueChange={(value: string) => setStatus(value as StatusCode)}
>
<SelectTrigger className="w-full p-3 text-gray-700">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y">Accept</SelectItem>
<SelectItem value="N">Reject</SelectItem>
<SelectItem value="W">Waiting</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-4 mt-8">
<Button
type="button"
onClick={() => handleEditDialog(false, null)}
disabled={loading}
className="px-6 py-3 h-12 border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 rounded-md"
>
Cancel
</Button>
<Button
type="submit"
disabled={loading}
className="px-6 py-3 h-12 bg-blue-600 hover:bg-blue-700 text-white rounded-md"
>
{loading ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</div>
</DialogContent>
</Dialog>
);
};
export default EditDialog;

View File

@ -1,199 +1,304 @@
import React, { useEffect, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useCallApi } from '@/hooks';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { apiConfig } from '@/config/api.config';
import { useCallApi } from '@/hooks';
import { format } from 'date-fns';
import { Image as ImageIcon, X } from 'lucide-react';
import { useManageFeedbackContext } from '../hooks/useManageFeedbackMemberContext';
import { Button } from '@/components/ui/button';
import FeedbackEditDialog from './EditDialog';
interface FeedbackDetailProps {
showDialog: boolean;
handleDialog: (show: boolean) => void;
feedbackId: string | null;
interface ImageModalProps {
isOpen: boolean;
onClose: () => void;
imageUrl: string;
}
interface FeedbackDetailData {
Feedback_id: string;
Feedback_feedback_notes: string;
Feedback_feedback_screenshoot: string;
Feedback_review_notes: string;
Feedback_emotion: string;
Feedback_review_screenshoot: string | null;
Feedback_category: string;
Feedback_status: string;
Feedback_created_at: string;
Feedback_review_by: string | null;
Feedback_review_at: string;
Feedback_deleted_by: string | null;
Feedback_deleted_at: string | null;
Feedback_createdById: string;
}
const API_URL = apiConfig.service_feedback;
const FeedbackDetail: React.FC<FeedbackDetailProps> = ({ showDialog, handleDialog, feedbackId }) => {
const [feedbackDetail, setFeedbackDetail] = useState<FeedbackDetailData | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const { GetData } = useCallApi();
useEffect(() => {
const fetchFeedbackDetail = async () => {
if (!feedbackId) return;
setLoading(true);
try {
const response = await GetData(`${API_URL}/feedback/${feedbackId}`,{});
if (response?.data) {
setFeedbackDetail(response.data);
}
} catch (error) {
console.error('Error fetching feedback detail:', error);
} finally {
setLoading(false);
}
};
if (showDialog && feedbackId) {
fetchFeedbackDetail();
} else {
setFeedbackDetail(null);
}
}, [showDialog, feedbackId, GetData]);
const formatDate = (dateString: string | null) => {
if (!dateString) return '-';
try {
return format(new Date(dateString), 'yyyy-MM-dd HH:mm:ss');
} catch (e) {
return dateString;
}
};
const getEmotionText = (emotion: string) => {
const emotions: Record<string, string> = {
'1': 'Very Disappointed',
'2': 'Disappointed',
'3': 'Happy',
'4': 'Very Happy',
'5': 'Extremely Happy'
};
return emotions[emotion] || emotion;
};
const getStatusText = (status: string) => {
const statuses: Record<string, string> = {
'W': 'Waiting',
'P': 'Processed',
'D': 'Done'
};
return statuses[status] || status;
};
// New Image Modal Component
const ImageModal: React.FC<ImageModalProps> = ({ isOpen, onClose, imageUrl }) => {
if (!isOpen) return null;
return (
<Dialog open={showDialog} onOpenChange={handleDialog}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Feedback Detail</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex justify-center items-center p-8">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
) : feedbackDetail ? (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-group">
<label className="form-label font-semibold">Feedback ID</label>
<div>{feedbackDetail.Feedback_id}</div>
</div>
<div className="form-group">
<label className="form-label font-semibold">Category</label>
<div>{feedbackDetail.Feedback_category}</div>
</div>
<div className="form-group">
<label className="form-label font-semibold">Emotion</label>
<div>{getEmotionText(feedbackDetail.Feedback_emotion)}</div>
</div>
<div className="form-group">
<label className="form-label font-semibold">Status</label>
<div>{getStatusText(feedbackDetail.Feedback_status)}</div>
</div>
<div className="form-group">
<label className="form-label font-semibold">Created At</label>
<div>{formatDate(feedbackDetail.Feedback_created_at)}</div>
</div>
<div className="form-group">
<label className="form-label font-semibold">Created By</label>
<div>{feedbackDetail.Feedback_createdById}</div>
</div>
</div>
<div className="form-group">
<label className="form-label font-semibold">Feedback Notes</label>
<div className="p-3 bg-gray-50 rounded">{feedbackDetail.Feedback_feedback_notes}</div>
</div>
{feedbackDetail.Feedback_feedback_screenshoot && (
<div className="form-group">
<label className="form-label font-semibold">Feedback Screenshot</label>
<div>
<img
src={feedbackDetail.Feedback_feedback_screenshoot.replace(/^'|'$/g, '')}
alt="Feedback Screenshot"
className="max-h-64 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).parentElement?.appendChild(
Object.assign(document.createElement('div'), {
className: 'text-sm text-gray-500',
textContent: 'Image not available or invalid URL'
})
);
}}
/>
</div>
</div>
)}
<div className="form-group">
<label className="form-label font-semibold">Review Notes</label>
<div className="p-3 bg-gray-50 rounded">{feedbackDetail.Feedback_review_notes || '-'}</div>
</div>
{feedbackDetail.Feedback_review_screenshoot && (
<div className="form-group">
<label className="form-label font-semibold">Review Screenshot</label>
<div>
<img
src={feedbackDetail.Feedback_review_screenshoot}
alt="Review Screenshot"
className="max-h-64 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).parentElement?.appendChild(
Object.assign(document.createElement('div'), {
className: 'text-sm text-gray-500',
textContent: 'Image not available or invalid URL'
})
);
}}
/>
</div>
</div>
)}
</div>
) : (
<div className="p-4 text-center text-gray-500">No feedback details found</div>
)}
</DialogContent>
</Dialog>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75">
<div className="relative w-11/12 h-5/6 max-w-4xl">
<button
onClick={onClose}
className="absolute top-4 right-4 bg-white rounded-full p-1 shadow-lg z-10"
>
<X size={24} className="text-gray-800" />
</button>
<img src={imageUrl} alt="Enlarged view" className="w-full h-full object-contain" />
</div>
</div>
);
};
export default FeedbackDetail;
const ScreenshotGrid = ({ title, images }: { title: string; images: string[] }) => {
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const openImageModal = (url: string) => {
setSelectedImage(url);
};
const closeImageModal = () => {
setSelectedImage(null);
};
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-gray-600">{title}</h3>
{images && images.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{images.map((url, index) => (
<div
key={`${title}-${index}`}
className="relative h-48 rounded-md overflow-hidden border border-gray-200 bg-white cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => url && openImageModal(url)}
>
{url ? (
<img
src={url}
alt={`${title} ${index + 1}`}
className="w-full h-full object-cover rounded-md"
/>
) : (
<div className="flex justify-center items-center h-full bg-gray-100">
<ImageIcon size={48} className="text-gray-400" />
</div>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-gray-800">N/A</p>
)}
<ImageModal
isOpen={!!selectedImage}
onClose={closeImageModal}
imageUrl={selectedImage || ''}
/>
</div>
);
};
const FeedbackDetail = () => {
const [loading, setLoading] = useState(false);
const [feedbackDetail, setFeedbackDetail] = useState<any>(null);
const { GetData } = useCallApi();
const API_URL = apiConfig.service_feedback;
const {
handleDetailDialog,
selectedFeedback,
showDetailDialog,
handleEditDialog,
showEditDialog
} = useManageFeedbackContext();
const formatDateTime = (dateString: string) => {
if (!dateString) return 'N/A';
try {
return format(new Date(dateString), 'MMM dd, yyyy HH:mm');
} catch {
return 'N/A';
}
};
const formatValue = (value: any) => {
if (value === null || value === undefined || (typeof value === 'string' && value.trim() === ''))
return 'N/A';
return value;
};
const getEmotionLabel = (emotion: string) => {
const emotions: Record<string, string> = {
'1': 'Very Dissatisfied',
'2': 'Dissatisfied',
'3': 'Neutral',
'4': 'Satisfied',
'5': 'Very Satisfied'
};
return emotions[emotion] || 'N/A';
};
const getStatusLabel = (status: string) => {
if (!status) return { label: 'N/A', color: 'text-gray-500' };
const statuses: Record<string, { label: string; color: string }> = {
W: { label: 'Waiting', color: 'text-yellow-500' },
Y: { label: 'Accepted', color: 'text-green-500' },
N: { label: 'Rejected', color: 'text-red-500' },
C: { label: 'Completed', color: 'text-blue-500' },
R: { label: 'Rejected', color: 'text-red-500' }
};
return statuses[status] || { label: 'N/A', color: 'text-gray-500' };
};
const fetchFeedbackDetail = async () => {
if (!selectedFeedback) return;
try {
setLoading(true);
const timestamp = new Date().getTime();
const response = await GetData(`${API_URL}/feedback/detail/${selectedFeedback}`, {
id: selectedFeedback,
_t: timestamp
});
if (!response || !response.data) {
console.error('Invalid response format');
return;
}
const data = response.data;
const normalizedData = {
...data,
feedback_screenshoot: Array.isArray(data?.feedback_screenshoot)
? data.feedback_screenshoot
: data?.feedback_screenshoot
? [data.feedback_screenshoot]
: [],
review_screenshoot: Array.isArray(data?.review_screenshoot)
? data.review_screenshoot
: data?.review_screenshoot
? [data.review_screenshoot]
: []
};
setFeedbackDetail(normalizedData);
} catch (error) {
console.error('Error fetching feedback detail:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (showDetailDialog && selectedFeedback) {
fetchFeedbackDetail();
} else if (!showDetailDialog) {
setFeedbackDetail(null);
}
}, [showDetailDialog, selectedFeedback]);
const handleReviewClick = () => {
handleEditDialog(true, selectedFeedback);
};
return (
<>
<Dialog
open={showDetailDialog}
onOpenChange={(open) => {
if (!open) {
setFeedbackDetail(null);
}
handleDetailDialog(open, open ? selectedFeedback : null);
}}
>
<DialogContent className="sm:max-w-4xl rounded-2xl p-6 shadow-lg border border-gray-200">
<DialogHeader>
<DialogTitle className="text-xl font-bold">Feedback Detail</DialogTitle>
<DialogDescription className="text-sm text-gray-500"></DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex justify-center items-center h-60">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary" />
</div>
) : feedbackDetail ? (
<div className="space-y-8">
<div className="p-4 border border-gray-200 rounded-lg">
<h3 className="text-md font-semibold mb-4 border-b pb-2">Feedback Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<p className="text-sm font-semibold text-gray-600">Feedback ID</p>
<p className="text-sm text-gray-800">{formatValue(feedbackDetail.id)}</p>
</div>
<div>
<p className="text-sm font-semibold text-gray-600">Created By</p>
<p className="text-sm text-gray-800">
{formatValue(feedbackDetail.created_by?.fullname)}
</p>
</div>
<div>
<p className="text-sm font-semibold text-gray-600">Created Date</p>
<p className="text-sm text-gray-800">
{formatDateTime(feedbackDetail.created_at)}
</p>
</div>
<div>
<p className="text-sm font-semibold text-gray-600">Emotion</p>
<p className="text-sm text-gray-800">
{getEmotionLabel(feedbackDetail.emotion)}
</p>
</div>
</div>
<div className="mt-4">
<p className="text-sm font-semibold text-gray-600">Feedback Notes</p>
<p className="text-sm text-gray-800 whitespace-pre-wrap mt-1 p-2 bg-gray-50 rounded">
{formatValue(feedbackDetail.feedback_notes)}
</p>
</div>
</div>
<div className="p-4 border border-gray-200 rounded-lg">
<h3 className="text-md font-semibold mb-4 border-b pb-2">Feedback Screenshots</h3>
<ScreenshotGrid title="" images={feedbackDetail.feedback_screenshoot} />
</div>
<div className="p-4 border border-gray-200 rounded-lg">
<h3 className="text-md font-semibold mb-4 border-b pb-2">Review Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<p className="text-sm font-semibold text-gray-600">Reviewed By</p>
<p className="text-sm text-gray-800">{formatValue(feedbackDetail.review_by)}</p>
</div>
<div>
<p className="text-sm font-semibold text-gray-600">Status</p>
<p
className={`text-sm font-semibold ${getStatusLabel(feedbackDetail.status).color}`}
>
{getStatusLabel(feedbackDetail.status).label}
</p>
</div>
</div>
<div className="mt-4">
<p className="text-sm font-semibold text-gray-600">Review Notes</p>
<p className="text-sm text-gray-800 whitespace-pre-wrap mt-1 p-2 bg-gray-50 rounded">
{formatValue(feedbackDetail.review_notes)}
</p>
</div>
</div>
{feedbackDetail.status !== 'C' && feedbackDetail.status !== 'R' && (
<div className="flex justify-end">
<Button
onClick={handleReviewClick}
className="bg-primary hover:bg-primary/90 text-white"
>
Review Feedback
</Button>
</div>
)}
</div>
) : null}
</DialogContent>
</Dialog>
</>
);
};
export default FeedbackDetail;