fix: cancel counting, redesigned locations, scanner defaults, uncounted items, and restored dashboard header

This commit is contained in:
2026-06-12 23:46:48 +03:30
parent fd9a57b96c
commit 37dd2e0f16
8 changed files with 409 additions and 176 deletions
+99 -50
View File
@@ -7,11 +7,11 @@ import { motion, AnimatePresence } from 'framer-motion';
export default function DiscrepancyDashboard() {
const [warehouse, setWarehouse] = useState('');
const [warehouses, setWarehouses] = useState([]);
const [data, setData] = useState({ discrepancies: [], accurate: [] });
const [data, setData] = useState({ discrepancies: [], accurate: [], uncounted: [] });
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState('all'); // all, surplus (مازاد), deficit (کسری)
const [filter, setFilter] = useState('all'); // all, surplus (مازاد), deficit (کسری), uncounted
useEffect(() => {
fetch('/api/settings')
@@ -35,11 +35,21 @@ export default function DiscrepancyDashboard() {
const fetchData = async () => {
setLoading(true);
try {
const res = await fetch(`/api/reports/discrepancies?warehouse=${warehouse}`);
if (res.ok) {
const result = await res.json();
setData(result);
const [resDiscrepancies, resUncounted] = await Promise.all([
fetch(`/api/reports/discrepancies?warehouse=${warehouse}`),
fetch(`/api/reports/uncounted?warehouse=${warehouse}`)
]);
let finalData = { discrepancies: [], accurate: [], uncounted: [] };
if (resDiscrepancies.ok) {
const result = await resDiscrepancies.json();
finalData = { ...finalData, ...result };
}
if (resUncounted.ok) {
const resultUncounted = await resUncounted.json();
finalData.uncounted = resultUncounted.uncounted || [];
}
setData(finalData);
} catch (e) {
console.error(e);
} finally {
@@ -52,8 +62,18 @@ export default function DiscrepancyDashboard() {
alert(`تسک بازشماری برای کالا "${productName}" (کد: ${productId}) صادر شد.`);
};
const filteredDiscrepancies = data.discrepancies.filter(item => {
const matchesSearch = item.product_name?.includes(search) || item.product_id?.toString().includes(search);
// We either show discrepancies or uncounted based on filter
const isUncountedMode = filter === 'uncounted';
let listToRender = isUncountedMode ? data.uncounted : data.discrepancies;
const filteredItems = listToRender.filter(item => {
const name = item.product_name || item.Name || '';
const id = (item.product_id || item.Code || '').toString();
const matchesSearch = name.includes(search) || id.includes(search);
if (isUncountedMode) return matchesSearch;
const matchesFilter =
filter === 'all' ? true :
filter === 'surplus' ? item.difference > 0 :
@@ -100,14 +120,24 @@ export default function DiscrepancyDashboard() {
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-4 relative z-10">
<div className="grid grid-cols-3 gap-4 relative z-10">
<div className="bg-white border border-red-100 rounded-[24px] p-5 shadow-sm flex flex-col gap-1 relative overflow-hidden">
<div className="absolute top-0 right-0 w-24 h-24 bg-red-50 rounded-bl-full flex items-start justify-end p-4 opacity-50 z-0">
<AlertTriangle className="text-red-300" size={32} />
</div>
<div className="relative z-10">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-wider mb-1">کالاهای دارای مغایرت</p>
<p className="text-3xl font-black text-red-600">{loading ? '-' : data.discrepancies.length}</p>
<p className="text-[10px] font-black text-gray-400 uppercase tracking-wider mb-1">مغایرتدار</p>
<p className="text-2xl font-black text-red-600">{loading ? '-' : data.discrepancies.length}</p>
</div>
</div>
<div className="bg-white border border-orange-100 rounded-[24px] p-5 shadow-sm flex flex-col gap-1 relative overflow-hidden">
<div className="absolute top-0 right-0 w-24 h-24 bg-orange-50 rounded-bl-full flex items-start justify-end p-4 opacity-50 z-0">
<PackageSearch className="text-orange-300" size={32} />
</div>
<div className="relative z-10">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-wider mb-1">شمارشنشده</p>
<p className="text-2xl font-black text-orange-500">{loading ? '-' : data.uncounted.length}</p>
</div>
</div>
@@ -116,8 +146,8 @@ export default function DiscrepancyDashboard() {
<CheckCircle className="text-green-300" size={32} />
</div>
<div className="relative z-10">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-wider mb-1">کالاهای بدون مغایرت</p>
<p className="text-3xl font-black text-green-600">{loading ? '-' : data.accurate.length}</p>
<p className="text-[10px] font-black text-gray-400 uppercase tracking-wider mb-1">صحیح</p>
<p className="text-2xl font-black text-green-600">{loading ? '-' : data.accurate.length}</p>
</div>
</div>
</div>
@@ -135,7 +165,7 @@ export default function DiscrepancyDashboard() {
/>
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-1 md:pb-0 scrollbar-hide">
{['all', 'surplus', 'deficit'].map(f => (
{['all', 'surplus', 'deficit', 'uncounted'].map(f => (
<button
key={f}
onClick={() => setFilter(f)}
@@ -145,80 +175,99 @@ export default function DiscrepancyDashboard() {
: 'bg-white text-gray-500 border-gray-200 hover:bg-gray-50'
}`}
>
{f === 'all' ? 'همه موارد' : f === 'surplus' ? 'مازاد (اضافه)' : 'کسری'}
{f === 'all' ? 'همه مغایرت‌ها' : f === 'surplus' ? 'مازاد' : f === 'deficit' ? 'کسری' : 'شمارش‌نشده'}
</button>
))}
</div>
</div>
{/* Discrepancies List (Card Based) */}
{/* Discrepancies / Uncounted List */}
<div className="flex flex-col gap-4 mt-2">
<div className="flex items-center gap-2 px-2">
<PackageSearch className="text-red-500" size={20} strokeWidth={2.5} />
<h3 className="font-black text-gray-800 text-sm">لیست کالاهای نیازمند بررسی</h3>
<PackageSearch className={isUncountedMode ? "text-orange-500" : "text-red-500"} size={20} strokeWidth={2.5} />
<h3 className="font-black text-gray-800 text-sm">
{isUncountedMode ? 'لیست کالاهای شمارش‌نشده' : 'لیست کالا‌های نیازمند بررسی'}
</h3>
<span className="bg-gray-200 text-gray-600 text-[10px] px-2 py-0.5 rounded-full font-bold mr-auto">
{filteredDiscrepancies.length} مورد
{filteredItems.length} مورد
</span>
</div>
<div className="flex flex-col gap-3">
<AnimatePresence>
{filteredDiscrepancies.map((item, idx) => (
{filteredItems.map((item, idx) => {
const isUncounted = isUncountedMode;
const pName = isUncounted ? item.Name : item.product_name;
const pId = isUncounted ? item.Code : item.product_id;
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
key={`${item.product_id}-${idx}`}
key={`${pId}-${idx}`}
className="bg-white rounded-[20px] shadow-sm border border-gray-100 p-5 flex flex-col gap-4"
>
{/* Top: Product Info & Badge */}
<div className="flex justify-between items-start">
<div className="flex flex-col pr-1">
<h4 className="font-bold text-gray-800 text-sm leading-snug">{item.product_name}</h4>
<p className="text-[10px] text-gray-400 mt-1 font-medium tracking-wider">کد حسابفا: {item.product_id}</p>
</div>
<div className={`flex items-center gap-1 px-3 py-1.5 rounded-[10px] text-xs font-black shadow-sm shrink-0 mr-3 ${item.difference > 0 ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-red-50 text-red-600 border border-red-100'}`}>
{item.difference > 0 ? <ArrowUpRight size={14} strokeWidth={3} /> : <ArrowDownRight size={14} strokeWidth={3} />}
{Math.abs(item.difference)}
<h4 className="font-bold text-gray-800 text-sm leading-snug">{pName}</h4>
<p className="text-[10px] text-gray-400 mt-1 font-medium tracking-wider">کد حسابفا: {pId}</p>
</div>
{!isUncounted && (
<div className={`flex items-center gap-1 px-3 py-1.5 rounded-[10px] text-xs font-black shadow-sm shrink-0 mr-3 ${item.difference > 0 ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-red-50 text-red-600 border border-red-100'}`}>
{item.difference > 0 ? <ArrowUpRight size={14} strokeWidth={3} /> : <ArrowDownRight size={14} strokeWidth={3} />}
{Math.abs(item.difference)}
</div>
)}
{isUncounted && (
<div className="flex items-center gap-1 px-3 py-1.5 rounded-[10px] text-xs font-black shadow-sm shrink-0 mr-3 bg-orange-50 text-orange-600 border border-orange-100">
موجودی: {item.Stock}
</div>
)}
</div>
{/* Middle: Stats */}
<div className="flex items-center bg-gray-50 rounded-[16px] p-3 border border-gray-100">
<div className="flex-1 flex flex-col items-center border-l border-gray-200">
<span className="text-[10px] font-bold text-gray-400 mb-0.5">موجودی سیستم</span>
<span className="text-lg font-black text-gray-600">{item.system_expected}</span>
{!isUncounted && (
<div className="flex items-center bg-gray-50 rounded-[16px] p-3 border border-gray-100">
<div className="flex-1 flex flex-col items-center border-l border-gray-200">
<span className="text-[10px] font-bold text-gray-400 mb-0.5">موجودی سیستم</span>
<span className="text-lg font-black text-gray-600">{item.system_expected}</span>
</div>
<div className="flex-1 flex flex-col items-center border-l border-gray-200">
<span className="text-[10px] font-bold text-gray-400 mb-0.5">شمارش شما</span>
<span className="text-lg font-black text-gray-900">{item.total_counted}</span>
</div>
<div className="flex-1 flex flex-col items-center">
<span className="text-[10px] font-bold text-gray-400 mb-0.5">قفسهها</span>
<span className="text-xs font-bold text-indigo-600 mt-1.5 line-clamp-1 text-center px-1">
{item.locations?.length > 0 ? item.locations.join('، ') : '-'}
</span>
</div>
</div>
<div className="flex-1 flex flex-col items-center border-l border-gray-200">
<span className="text-[10px] font-bold text-gray-400 mb-0.5">شمارش شما</span>
<span className="text-lg font-black text-gray-900">{item.total_counted}</span>
</div>
<div className="flex-1 flex flex-col items-center">
<span className="text-[10px] font-bold text-gray-400 mb-0.5">قفسهها</span>
<span className="text-xs font-bold text-indigo-600 mt-1.5 line-clamp-1 text-center px-1">
{item.locations.length > 0 ? item.locations.join('، ') : '-'}
</span>
</div>
</div>
)}
{/* Bottom: Actions */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-gray-50 mt-1">
<button
onClick={() => handleRecount(item.product_id, item.product_name)}
onClick={() => handleRecount(pId, pName)}
className="bg-orange-50 text-orange-600 hover:bg-orange-500 hover:text-white px-4 py-2.5 rounded-[14px] text-xs font-bold transition-colors flex-1 text-center"
>
ارجاع بازشماری
</button>
<button className="bg-gray-900 text-white hover:bg-gray-800 px-4 py-2.5 rounded-[14px] text-xs font-bold transition-colors flex items-center justify-center gap-1.5 flex-1 shadow-md">
<Send size={14} />
ارسال تعدیل
</button>
{!isUncounted && (
<button className="bg-gray-900 text-white hover:bg-gray-800 px-4 py-2.5 rounded-[14px] text-xs font-bold transition-colors flex items-center justify-center gap-1.5 flex-1 shadow-md">
<Send size={14} />
ارسال تعدیل
</button>
)}
</div>
</motion.div>
))}
);
})}
</AnimatePresence>
{filteredDiscrepancies.length === 0 && !loading && (
{filteredItems.length === 0 && !loading && (
<div className="bg-white rounded-[24px] border border-dashed border-gray-300 flex flex-col items-center justify-center p-10 text-center gap-3">
<div className="w-16 h-16 bg-green-50 text-green-500 rounded-full flex items-center justify-center">
<CheckCircle size={32} />
@@ -231,7 +280,7 @@ export default function DiscrepancyDashboard() {
{loading && (
<div className="bg-white rounded-[24px] border border-gray-100 flex flex-col items-center justify-center p-10 text-center gap-3">
<div className="w-10 h-10 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
<p className="text-sm font-bold text-gray-500">در حال بررسی و مقایسه اطلاعات...</p>
<p className="text-sm font-bold text-gray-500">در حال بررسی و استخراج اطلاعات...</p>
</div>
)}
</div>
+99 -29
View File
@@ -3,21 +3,26 @@ import { useState, useEffect } from 'react';
import Header from '@/components/Header';
import dynamic from 'next/dynamic';
import { motion, AnimatePresence } from 'framer-motion';
import { ScanLine, Plus, Search, Trash2, Edit2, Layers, MapPin, X, AlertCircle, XCircle, Box } from 'lucide-react';
import { ScanLine, Plus, Trash2, Edit2, Layers, MapPin, X, AlertCircle, XCircle, Box } from 'lucide-react';
const Scanner = dynamic(() => import('@yudiel/react-qr-scanner').then(mod => mod.Scanner), { ssr: false });
export default function AdminLocations() {
const [locations, setLocations] = useState([]);
const [warehouses, setWarehouses] = useState([]);
const [selectedWarehouse, setSelectedWarehouse] = useState('');
const [newCode, setNewCode] = useState('');
// Separate states for location parts
const [floor, setFloor] = useState('');
const [region, setRegion] = useState('');
const [sector, setSector] = useState('');
const [row, setRow] = useState('');
const [loading, setLoading] = useState(false);
const [cameraEnabled, setCameraEnabled] = useState(false);
const [cameraEnabled, setCameraEnabled] = useState(true);
const [camError, setCamError] = useState('');
const [activeFilter, setActiveFilter] = useState('all');
const [editingId, setEditingId] = useState(null);
const [editCode, setEditCode] = useState('');
const [toast, setToast] = useState({ show: false, message: '', isError: false });
@@ -57,14 +62,37 @@ export default function AdminLocations() {
}
};
const handleAddOrEdit = async (codeValue) => {
const targetCode = editingId ? editCode : (codeValue || newCode);
if (!targetCode) return;
// Smart Parser for the main code input
const handleFullCodeChange = (e) => {
const val = e.target.value.toUpperCase();
// Attempt to parse regex: [Letter][Number][Letter][Number] e.g. C2F3
const regex = /^([A-Za-z]+)(\d+)([A-Za-z]+)(\d+)$/;
const match = val.match(regex);
if (match) {
setFloor(match[1]);
setRegion(match[2]);
setSector(match[3]);
setRow(match[4]);
} else {
// If it doesn't match perfectly, just use it as floor or something, but best to let users type in separate fields
// We will just leave it empty or partially fill. Actually, it's better to clear them or let them type.
}
};
const handleAddOrEdit = async () => {
if (!floor || !region || !sector || !row) {
showToast('لطفا همه مقادیر طبقه، منطقه، قطاع و ردیف را وارد کنید', true);
return;
}
if (!selectedWarehouse) {
showToast('لطفاً ابتدا انبار را انتخاب کنید', true);
return;
}
const generatedCode = `${floor.toUpperCase()}${region}${sector.toUpperCase()}${row}`;
setLoading(true);
try {
const method = editingId ? 'PUT' : 'POST';
@@ -73,15 +101,14 @@ export default function AdminLocations() {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: targetCode, warehouse: selectedWarehouse })
body: JSON.stringify({ code: generatedCode, warehouse: selectedWarehouse })
});
const data = await res.json();
if (res.ok) {
showToast(editingId ? 'قفسه با موفقیت ویرایش شد' : 'قفسه با موفقیت ثبت شد!');
setNewCode('');
setFloor(''); setRegion(''); setSector(''); setRow('');
setEditingId(null);
setEditCode('');
fetchLocations();
} else {
showToast(data.error || 'خطا در ثبت قفسه', true);
@@ -112,8 +139,20 @@ export default function AdminLocations() {
const handleScan = (detectedCodes) => {
if (detectedCodes && detectedCodes.length > 0) {
const scannedValue = detectedCodes[0].rawValue;
handleAddOrEdit(scannedValue);
const scannedValue = detectedCodes[0].rawValue.toUpperCase();
const regex = /^([A-Za-z]+)(\d+)([A-Za-z]+)(\d+)$/;
const match = scannedValue.match(regex);
if (match) {
setFloor(match[1]);
setRegion(match[2]);
setSector(match[3]);
setRow(match[4]);
showToast(`کد ${scannedValue} شناسایی و پر شد`);
// Optional: auto-submit here if desired
// setTimeout(handleAddOrEdit, 500);
} else {
showToast(`کد اسکن شده نامعتبر است: ${scannedValue}`, true);
}
}
};
@@ -136,6 +175,8 @@ export default function AdminLocations() {
filteredLocations = filteredLocations.filter(loc => loc.floor === activeFilter);
}
const generatedCodePreview = (floor || region || sector || row) ? `${floor.toUpperCase()}${region}${sector.toUpperCase()}${row}` : '';
return (
<div className="w-full min-h-screen bg-gray-50 flex flex-col pb-24 relative">
<Header title="مدیریت قفسه‌ها" showBack={true} />
@@ -148,7 +189,7 @@ export default function AdminLocations() {
<div className="flex justify-between items-center mb-1">
<span className="text-sm font-bold text-gray-700">{editingId ? 'ویرایش قفسه' : 'ثبت قفسه جدید'}</span>
{editingId && (
<button onClick={() => { setEditingId(null); setEditCode(''); }} className="text-xs text-red-500 font-bold bg-red-50 px-2 py-1 rounded-lg">
<button onClick={() => { setEditingId(null); setFloor(''); setRegion(''); setSector(''); setRow(''); }} className="text-xs text-red-500 font-bold bg-red-50 px-2 py-1 rounded-lg">
لغو ویرایش
</button>
)}
@@ -165,25 +206,47 @@ export default function AdminLocations() {
))}
</select>
<div className="flex gap-2">
{/* Smart Code Input */}
<div>
<label className="text-[10px] font-bold text-gray-400 mr-2 mb-1 block">اسکن یا تایپ کد قفسه (مثال: C2F3)</label>
<input
type="text"
dir="ltr"
value={editingId ? editCode : newCode}
onChange={(e) => editingId ? setEditCode(e.target.value) : setNewCode(e.target.value)}
placeholder="مثال: C2F2"
className="flex-1 bg-gray-50 border border-gray-200 rounded-[16px] px-4 py-3 text-center uppercase font-black text-gray-800 tracking-widest focus:outline-none focus:border-indigo-500 focus:bg-white transition-colors placeholder-gray-300"
onChange={handleFullCodeChange}
placeholder="C2F3"
className="w-full bg-indigo-50/50 border border-indigo-100 rounded-[16px] px-4 py-3 text-center uppercase font-black text-indigo-700 tracking-widest focus:outline-none focus:border-indigo-400 focus:bg-indigo-50 transition-colors placeholder-indigo-200"
/>
<motion.button
whileTap={{ scale: 0.95 }}
onClick={() => handleAddOrEdit()}
disabled={loading}
className="w-14 bg-gray-900 text-white rounded-[16px] flex items-center justify-center transition-opacity disabled:opacity-50 shrink-0"
>
{loading ? <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> : (editingId ? <Edit2 size={18} strokeWidth={2.5}/> : <Plus size={20} strokeWidth={3} />)}
</motion.button>
</div>
<div className="grid grid-cols-4 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[10px] text-gray-400 font-bold text-center">طبقه</label>
<input type="text" dir="ltr" value={floor} onChange={e => setFloor(e.target.value)} placeholder="C" className="w-full bg-gray-50 border border-gray-200 rounded-xl px-2 py-2 text-center uppercase font-bold text-gray-800 focus:outline-none focus:border-indigo-500" />
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-gray-400 font-bold text-center">منطقه</label>
<input type="number" dir="ltr" value={region} onChange={e => setRegion(e.target.value)} placeholder="2" className="w-full bg-gray-50 border border-gray-200 rounded-xl px-2 py-2 text-center font-bold text-gray-800 focus:outline-none focus:border-indigo-500" />
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-gray-400 font-bold text-center">قطاع</label>
<input type="text" dir="ltr" value={sector} onChange={e => setSector(e.target.value)} placeholder="F" className="w-full bg-gray-50 border border-gray-200 rounded-xl px-2 py-2 text-center uppercase font-bold text-gray-800 focus:outline-none focus:border-indigo-500" />
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-gray-400 font-bold text-center">ردیف</label>
<input type="number" dir="ltr" value={row} onChange={e => setRow(e.target.value)} placeholder="3" className="w-full bg-gray-50 border border-gray-200 rounded-xl px-2 py-2 text-center font-bold text-gray-800 focus:outline-none focus:border-indigo-500" />
</div>
</div>
<motion.button
whileTap={{ scale: 0.98 }}
onClick={handleAddOrEdit}
disabled={loading || !generatedCodePreview}
className="w-full bg-gray-900 text-white py-3.5 rounded-[16px] text-sm font-black flex items-center justify-center gap-2 transition-opacity disabled:opacity-50 mt-1"
>
{loading ? <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> : (editingId ? <Edit2 size={18}/> : <Plus size={20}/>)}
{editingId ? `ویرایش ${generatedCodePreview}` : `ثبت قفسه ${generatedCodePreview}`}
</motion.button>
<div className="relative overflow-hidden rounded-[16px]">
<AnimatePresence>
{cameraEnabled && (
@@ -214,7 +277,7 @@ export default function AdminLocations() {
<motion.button
whileTap={{ scale: 0.98 }}
onClick={() => setCameraEnabled(true)}
className="w-full py-3.5 bg-indigo-50 text-indigo-600 text-sm font-extrabold rounded-[16px] transition-colors flex items-center justify-center gap-2"
className="w-full py-3.5 bg-indigo-50 text-indigo-600 text-sm font-extrabold rounded-[16px] transition-colors flex items-center justify-center gap-2 mt-2"
>
<ScanLine size={18} strokeWidth={2.5} />
اسکن بارکد قفسه
@@ -269,7 +332,7 @@ export default function AdminLocations() {
<div className="flex flex-col">
<span className="font-black text-lg text-gray-800 tracking-wide uppercase">{loc.code}</span>
<span className="text-[10px] text-gray-400 font-bold mt-0.5">
{loc.warehouse ? `انبار: ${loc.warehouse}` : ''}طبقه {loc.floor} منطقه {loc.region} قطاع {loc.sector}
{loc.warehouse ? `انبار: ${loc.warehouse}` : ''}طبقه {loc.floor} منطقه {loc.region} قطاع {loc.sector} ردیف {loc.row}
</span>
</div>
</div>
@@ -278,7 +341,14 @@ export default function AdminLocations() {
{!hasData ? (
<>
<button
onClick={() => { setEditingId(loc.id); setEditCode(loc.code); window.scrollTo({ top: 0, behavior: 'smooth' }); }}
onClick={() => {
setEditingId(loc.id);
setFloor(loc.floor);
setRegion(loc.region);
setSector(loc.sector);
setRow(loc.row);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="w-8 h-8 flex items-center justify-center rounded-xl text-gray-400 hover:text-indigo-500 hover:bg-indigo-50 transition-colors"
>
<Edit2 size={16} strokeWidth={2.5} />
+114 -26
View File
@@ -1,8 +1,8 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, use } from 'react';
import Header from '@/components/Header';
import { use } from 'react';
import { User as UserIcon, Calendar, Package, ArrowRight, ShieldCheck } from 'lucide-react';
import { User as UserIcon, Calendar, Package, Layers, CheckCircle2, AlertTriangle, ArrowLeft } from 'lucide-react';
import { motion } from 'framer-motion';
export default function UserProfilePage({ params }) {
const unwrappedParams = use(params);
@@ -16,11 +16,9 @@ export default function UserProfilePage({ params }) {
const fetchUserData = async () => {
try {
// In a full implementation, we'd have /api/users/[id]
// For now, we fetch all users and filter, and also fetch their countings
const [usersRes, countingsRes] = await Promise.all([
fetch('/api/users'),
fetch(`/api/reports/countings?userId=${unwrappedParams.id}`) // Assuming such an API or similar exists
fetch(`/api/counting?user_id=${unwrappedParams.id}`)
]);
if (usersRes.ok) {
@@ -29,12 +27,9 @@ export default function UserProfilePage({ params }) {
setUser(found);
}
// Since /api/reports/countings might not support userId yet,
// let's just pretend we have it or gracefully fallback.
if (countingsRes.ok) {
const data = await countingsRes.json();
// Fallback for demo if API doesn't support filter
setCountings(data.filter ? data.filter(c => c.userId === parseInt(unwrappedParams.id)) : []);
setCountings(data);
}
} catch (e) {
console.error(e);
@@ -65,8 +60,17 @@ export default function UserProfilePage({ params }) {
);
}
// Split countings
const shelfCountings = countings.filter(c => c.mode === 'SHELF').slice(0, 10);
const itemCountings = countings.filter(c => c.mode === 'ITEM').slice(0, 10);
// Calc accuracy
const validCounts = countings.filter(c => c.status !== 'CANCELLED');
const discrepancies = validCounts.filter(c => c.old_count !== c.new_count).length;
const accuracy = validCounts.length > 0 ? (((validCounts.length - discrepancies) / validCounts.length) * 100).toFixed(1) : 0;
return (
<div className="w-full min-h-screen bg-gray-50 flex flex-col pb-24">
<div className="w-full min-h-screen bg-gray-50 flex flex-col pb-24 relative overflow-x-hidden">
<Header title="کارنامه عملکرد" showBack={true} />
<div className="flex-1 p-4 md:p-6 flex flex-col gap-6 max-w-2xl mx-auto w-full mt-2">
@@ -75,8 +79,8 @@ export default function UserProfilePage({ params }) {
<div className="bg-white rounded-[24px] p-6 shadow-sm border border-gray-100 flex flex-col items-center relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-24 bg-gradient-to-br from-indigo-500 to-purple-600 opacity-10"></div>
<div className="w-20 h-20 bg-white border-4 border-gray-50 text-indigo-600 rounded-full flex items-center justify-center font-black text-3xl shadow-sm z-10 mb-4">
{user.name ? user.name.charAt(0) : '?'}
<div className="w-20 h-20 bg-white border-4 border-gray-50 text-indigo-600 rounded-full flex items-center justify-center font-black text-3xl shadow-sm z-10 mb-4 overflow-hidden">
{user.avatarUrl ? <img src={user.avatarUrl} className="w-full h-full object-cover" alt="" /> : (user.name ? user.name.charAt(0) : '?')}
</div>
<h2 className="text-xl font-black text-gray-800 z-10">{user.name || 'کاربر بدون نام'}</h2>
@@ -92,25 +96,109 @@ export default function UserProfilePage({ params }) {
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-white rounded-[20px] p-5 shadow-sm border border-gray-100 flex flex-col gap-2">
<div className="w-10 h-10 bg-blue-50 text-blue-500 rounded-xl flex items-center justify-center">
<Package size={20} strokeWidth={2.5} />
</div>
<span className="text-xs font-bold text-gray-400 mt-2">مجموع شمارشها</span>
<span className="text-2xl font-black text-gray-800">{user._count?.countings || 0}</span>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-[20px] p-5 shadow-sm border border-gray-100 flex flex-col gap-2 items-center justify-center text-center">
<Package size={24} className="text-indigo-500 mb-1" strokeWidth={2} />
<span className="text-[10px] font-bold text-gray-400">مجموع شمارش</span>
<span className="text-xl font-black text-gray-800">{countings.length}</span>
</div>
<div className="bg-white rounded-[20px] p-5 shadow-sm border border-gray-100 flex flex-col gap-2">
<div className="w-10 h-10 bg-green-50 text-green-500 rounded-xl flex items-center justify-center">
<Calendar size={20} strokeWidth={2.5} />
</div>
<span className="text-xs font-bold text-gray-400 mt-2">تاریخ عضویت</span>
<span className="text-sm font-black text-gray-800 mt-1" dir="ltr">
<div className="bg-white rounded-[20px] p-5 shadow-sm border border-gray-100 flex flex-col gap-2 items-center justify-center text-center">
<CheckCircle2 size={24} className="text-green-500 mb-1" strokeWidth={2} />
<span className="text-[10px] font-bold text-gray-400">دقت عملکرد</span>
<span className="text-xl font-black text-green-600">{accuracy}%</span>
</div>
<div className="bg-white rounded-[20px] p-5 shadow-sm border border-gray-100 flex flex-col gap-2 items-center justify-center text-center">
<AlertTriangle size={24} className="text-red-500 mb-1" strokeWidth={2} />
<span className="text-[10px] font-bold text-gray-400">مغایرتها</span>
<span className="text-xl font-black text-red-600">{discrepancies}</span>
</div>
<div className="bg-white rounded-[20px] p-5 shadow-sm border border-gray-100 flex flex-col gap-2 items-center justify-center text-center">
<Calendar size={24} className="text-blue-500 mb-1" strokeWidth={2} />
<span className="text-[10px] font-bold text-gray-400">تاریخ عضویت</span>
<span className="text-[10px] font-black text-gray-800 mt-1" dir="ltr">
{new Date(user.createdAt).toLocaleDateString('fa-IR')}
</span>
</div>
</div>
{/* Recent Countings by SHELF */}
<div className="bg-white rounded-[24px] shadow-sm border border-gray-100 overflow-hidden">
<div className="p-4 border-b border-gray-100 bg-gray-50 flex items-center justify-between">
<h3 className="font-bold text-gray-800 text-sm flex items-center gap-2">
<Layers size={18} className="text-purple-500" />
آخرین انبارگردانی قفسهای
</h3>
<span className="text-[10px] font-bold text-gray-500 bg-white px-2 py-1 rounded-md border border-gray-100">
{shelfCountings.length} رکورد آخر
</span>
</div>
<div className="p-4 flex flex-col gap-3">
{shelfCountings.map(c => (
<div key={c.id} className="flex flex-col gap-2 p-3 bg-gray-50 rounded-xl border border-gray-100">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-gray-800">{c.product_name}</span>
<span className="text-[10px] text-gray-400">{new Date(c.createdAt).toLocaleTimeString('fa-IR', {hour: '2-digit', minute:'2-digit'})}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold bg-white text-gray-600 px-2 py-1 rounded-md border border-gray-200">
قفسه: {c.shelfCode}
</span>
{c.status === 'CANCELLED' && <span className="text-[10px] font-bold text-red-500">لغو شده</span>}
</div>
<div className="flex items-center gap-1 font-black text-sm">
<span className="text-gray-400 line-through text-[10px] ml-1">{c.old_count}</span>
<ArrowLeft size={12} className="text-gray-300" />
<span className={c.old_count === c.new_count ? 'text-green-600' : 'text-red-500'}>{c.new_count}</span>
</div>
</div>
</div>
))}
{shelfCountings.length === 0 && (
<div className="text-center text-xs text-gray-400 font-bold py-4">موردی یافت نشد</div>
)}
</div>
</div>
{/* Recent Countings by ITEM */}
<div className="bg-white rounded-[24px] shadow-sm border border-gray-100 overflow-hidden">
<div className="p-4 border-b border-gray-100 bg-gray-50 flex items-center justify-between">
<h3 className="font-bold text-gray-800 text-sm flex items-center gap-2">
<Package size={18} className="text-teal-500" />
آخرین انبارگردانی کالایی
</h3>
<span className="text-[10px] font-bold text-gray-500 bg-white px-2 py-1 rounded-md border border-gray-100">
{itemCountings.length} رکورد آخر
</span>
</div>
<div className="p-4 flex flex-col gap-3">
{itemCountings.map(c => (
<div key={c.id} className="flex flex-col gap-2 p-3 bg-gray-50 rounded-xl border border-gray-100">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-gray-800">{c.product_name}</span>
<span className="text-[10px] text-gray-400">{new Date(c.createdAt).toLocaleTimeString('fa-IR', {hour: '2-digit', minute:'2-digit'})}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold bg-white text-gray-600 px-2 py-1 rounded-md border border-gray-200">
کد حسابفا: {c.product_id}
</span>
{c.status === 'CANCELLED' && <span className="text-[10px] font-bold text-red-500">لغو شده</span>}
</div>
<div className="flex items-center gap-1 font-black text-sm">
<span className="text-gray-400 line-through text-[10px] ml-1">{c.old_count}</span>
<ArrowLeft size={12} className="text-gray-300" />
<span className={c.old_count === c.new_count ? 'text-green-600' : 'text-red-500'}>{c.new_count}</span>
</div>
</div>
</div>
))}
{itemCountings.length === 0 && (
<div className="text-center text-xs text-gray-400 font-bold py-4">موردی یافت نشد</div>
)}
</div>
</div>
</div>
</div>
);
+7 -5
View File
@@ -37,17 +37,19 @@ export async function POST(req) {
});
// 2. Unlock the location
await prisma.location.updateMany({
where: { code: shelfCode.toUpperCase(), warehouse: Number(warehouse) },
data: { isLocked: false, lockedById: null, lockedAt: null }
});
if (shelfCode) {
await prisma.location.updateMany({
where: { code: shelfCode.toUpperCase(), warehouse: Number(warehouse) },
data: { isLocked: false, lockedById: null, lockedAt: null }
});
}
// 3. Log the action
await prisma.actionLog.create({
data: {
userId: Number(userId),
action: 'CANCEL_COUNTING',
details: `لغو شمارش ${mode === 'SHELF' ? 'قفسه' : 'کالا'} ${shelfCode.toUpperCase()} به دلیل: ${reason}`
details: `لغو شمارش ${mode === 'SHELF' ? 'قفسه' : 'کالا'} ${shelfCode ? shelfCode.toUpperCase() : product_id} به دلیل: ${reason}`
}
});
+32 -35
View File
@@ -1,51 +1,48 @@
import prisma from '@/lib/prisma';
import { NextResponse } from 'next/server';
import axios from 'axios';
const HESABFA_API_KEY = process.env.HESABFA_API_KEY || 'NCuDX3bksHlhXWGIqTvatvme3YTplxdF';
const HESABFA_TOKEN = process.env.HESABFA_TOKEN || '4ddb2fc517f6f6fe6d4b9bdd08fa0df31a564a62e12c4353eb9533ae63447b57ca87c479beb7f02b276929c861dad779';
export async function GET(req) {
const { searchParams } = new URL(req.url);
const warehouse = searchParams.get('warehouse') || '11';
try {
const url = new URL(req.url);
const thresholdDays = parseInt(url.searchParams.get('days')) || 10;
// Calculate the cutoff date
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - thresholdDays);
// Get all locations
const locations = await prisma.location.findMany({
select: {
id: true,
code: true,
warehouse: true,
floor: true,
region: true,
sector: true
}
// 1. Fetch countings
const countings = await prisma.counting.findMany({
where: { warehouse: Number(warehouse), status: { not: 'CANCELLED' } }
});
// Get the most recent counting date for each location
// Prisma group by or finding max date per shelf
const latestCounts = await prisma.counting.groupBy({
by: ['shelfCode'],
_max: {
createdAt: true
}
// Get distinct product IDs that were counted
const countedProductIds = new Set(countings.map(c => Number(c.product_id)));
// 2. Fetch all products from Hesabfa
const res = await axios.post('https://api.hesabfa.com/v1/item/getitems', {
apiKey: HESABFA_API_KEY,
loginToken: HESABFA_TOKEN,
queryInfo: { Take: 2000, Skip: 0 },
type: 0
});
const countMap = new Map();
latestCounts.forEach(c => {
countMap.set(c.shelfCode, c._max.createdAt);
});
const hesabfaItems = res.data?.Result?.List || [];
// Filter locations that have either NEVER been counted, or the latest count is older than cutoff
const uncounted = locations.filter(loc => {
const lastCountDate = countMap.get(loc.code);
if (!lastCountDate) return true; // Never counted
return new Date(lastCountDate) < cutoffDate;
// 3. Filter items that have Stock > 0 but are not in countedProductIds
const uncounted = hesabfaItems.filter(item => {
// Check if not counted
if (countedProductIds.has(item.Code)) return false;
// Check if it has stock (assuming item.Stock or finding via another property, some accounts use item.Stock)
// Since getitems might not return Stock per warehouse, we assume if Stock > 0 it should have been counted.
const stock = Number(item.Stock) || 0;
return stock > 0;
});
return NextResponse.json({ uncounted });
} catch (error) {
console.error('Fetch uncounted error:', error);
return NextResponse.json({ error: 'خطا در دریافت لیست قفسه‌ها' }, { status: 500 });
console.error('Uncounted Error:', error);
return NextResponse.json({ error: 'خطا در محاسبه کالاهای شمارش نشده' }, { status: 500 });
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ function ItemCountingContent() {
const [submitLoading, setSubmitLoading] = useState(false);
const [history, setHistory] = useState([]);
const [cameraEnabled, setCameraEnabled] = useState(false);
const [cameraEnabled, setCameraEnabled] = useState(true);
const [camError, setCamError] = useState('');
const [errorMsg, setErrorMsg] = useState('');
+1 -1
View File
@@ -30,7 +30,7 @@ function ShelfCountingContent() {
const [submitLoading, setSubmitLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
const [cameraEnabled, setCameraEnabled] = useState(false);
const [cameraEnabled, setCameraEnabled] = useState(true);
const [camError, setCamError] = useState('');
// History of scanned items in this session
+56 -29
View File
@@ -29,46 +29,73 @@ export default function Header({ title = 'داشبورد', showBack = false }) {
return name.charAt(0);
};
if (!showBack) {
return (
<motion.header
initial={{ y: -50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 24 }}
className="mx-4 mt-6 mb-4 p-2 bg-white/70 backdrop-blur-xl border border-white rounded-[28px] flex items-center justify-between shadow-[0_8px_30px_rgb(0,0,0,0.04)]"
>
<Link href="/settings" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
<div className="w-12 h-12 bg-gradient-to-tr from-purple-600 to-indigo-500 rounded-[20px] flex items-center justify-center text-white text-lg font-bold shadow-md shadow-purple-500/20 overflow-hidden">
{user?.avatarUrl ? (
<img src={user.avatarUrl} alt={user.name} className="w-full h-full object-cover" />
) : (
getInitial(user?.name)
)}
</div>
<div className="flex flex-col pr-1">
<span className="text-[10px] text-gray-400 font-extrabold tracking-wider mb-0.5">پردیس رایانه</span>
<span className="text-sm font-black text-gray-800 tracking-tight">
{user?.name || 'کاربر'}
</span>
</div>
</Link>
<div className="flex items-center gap-2 pl-1">
{user?.roles?.includes('ADMIN') && (
<Link
href="/admin"
className="w-10 h-10 bg-gray-50 text-gray-600 rounded-[18px] hover:bg-gray-100 transition-colors flex items-center justify-center"
title="پنل مدیریت"
>
<Settings size={18} strokeWidth={2.5} />
</Link>
)}
<button
onClick={handleLogout}
className="w-10 h-10 bg-red-50 text-red-500 rounded-[18px] hover:bg-red-100 transition-colors flex items-center justify-center"
title="خروج"
>
<LogOut size={18} strokeWidth={2.5} />
</button>
</div>
</motion.header>
);
}
// Inner pages sticky header
return (
<motion.header
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
className="sticky top-4 z-50 mx-4 mb-6 p-2 bg-white/80 backdrop-blur-xl border border-white rounded-[24px] flex items-center justify-between shadow-[0_8px_30px_rgb(0,0,0,0.06)]"
>
<div className="flex items-center gap-2">
{showBack ? (
<button
onClick={() => router.back()}
className="w-10 h-10 bg-gray-50 text-gray-700 rounded-[18px] hover:bg-gray-100 transition-all flex items-center justify-center"
>
<ChevronRight size={20} strokeWidth={2.5} />
</button>
) : (
<div className="w-10 h-10 bg-indigo-50 text-indigo-600 rounded-[18px] flex items-center justify-center font-black text-sm">
{getInitial(user?.name)}
</div>
)}
</div>
<button
onClick={() => router.back()}
className="w-10 h-10 bg-gray-50 text-gray-700 rounded-[18px] hover:bg-gray-100 transition-all flex items-center justify-center"
>
<ChevronRight size={20} strokeWidth={2.5} />
</button>
<div className="font-extrabold text-sm text-gray-800 absolute left-1/2 -translate-x-1/2 whitespace-nowrap">
{title}
</div>
<div className="flex items-center gap-1.5">
{!showBack && user?.roles?.includes('ADMIN') && (
<Link
href="/admin"
className="w-10 h-10 bg-gray-50 text-gray-600 rounded-[18px] hover:bg-gray-100 transition-colors flex items-center justify-center"
>
<Settings size={18} strokeWidth={2.5} />
</Link>
)}
<button
onClick={handleLogout}
className="w-10 h-10 bg-red-50 text-red-500 rounded-[18px] hover:bg-red-100 transition-colors flex items-center justify-center"
>
<LogOut size={16} strokeWidth={2.5} />
</button>
<div className="w-10 h-10 flex items-center justify-center">
{/* Decorative dot for symmetry */}
<div className="w-1.5 h-1.5 rounded-full bg-gray-300"></div>
</div>
</motion.header>
);