fix: cancel counting, redesigned locations, scanner defaults, uncounted items, and restored dashboard header
This commit is contained in:
@@ -7,11 +7,11 @@ import { motion, AnimatePresence } from 'framer-motion';
|
|||||||
export default function DiscrepancyDashboard() {
|
export default function DiscrepancyDashboard() {
|
||||||
const [warehouse, setWarehouse] = useState('');
|
const [warehouse, setWarehouse] = useState('');
|
||||||
const [warehouses, setWarehouses] = useState([]);
|
const [warehouses, setWarehouses] = useState([]);
|
||||||
const [data, setData] = useState({ discrepancies: [], accurate: [] });
|
const [data, setData] = useState({ discrepancies: [], accurate: [], uncounted: [] });
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filter, setFilter] = useState('all'); // all, surplus (مازاد), deficit (کسری)
|
const [filter, setFilter] = useState('all'); // all, surplus (مازاد), deficit (کسری), uncounted
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/settings')
|
fetch('/api/settings')
|
||||||
@@ -35,11 +35,21 @@ export default function DiscrepancyDashboard() {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/reports/discrepancies?warehouse=${warehouse}`);
|
const [resDiscrepancies, resUncounted] = await Promise.all([
|
||||||
if (res.ok) {
|
fetch(`/api/reports/discrepancies?warehouse=${warehouse}`),
|
||||||
const result = await res.json();
|
fetch(`/api/reports/uncounted?warehouse=${warehouse}`)
|
||||||
setData(result);
|
]);
|
||||||
|
|
||||||
|
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) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -52,8 +62,18 @@ export default function DiscrepancyDashboard() {
|
|||||||
alert(`تسک بازشماری برای کالا "${productName}" (کد: ${productId}) صادر شد.`);
|
alert(`تسک بازشماری برای کالا "${productName}" (کد: ${productId}) صادر شد.`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredDiscrepancies = data.discrepancies.filter(item => {
|
// We either show discrepancies or uncounted based on filter
|
||||||
const matchesSearch = item.product_name?.includes(search) || item.product_id?.toString().includes(search);
|
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 =
|
const matchesFilter =
|
||||||
filter === 'all' ? true :
|
filter === 'all' ? true :
|
||||||
filter === 'surplus' ? item.difference > 0 :
|
filter === 'surplus' ? item.difference > 0 :
|
||||||
@@ -100,14 +120,24 @@ export default function DiscrepancyDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* 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="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">
|
<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} />
|
<AlertTriangle className="text-red-300" size={32} />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-wider mb-1">کالاهای دارای مغایرت</p>
|
<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-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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,8 +146,8 @@ export default function DiscrepancyDashboard() {
|
|||||||
<CheckCircle className="text-green-300" size={32} />
|
<CheckCircle className="text-green-300" size={32} />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-wider mb-1">کالاهای بدون مغایرت</p>
|
<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-2xl font-black text-green-600">{loading ? '-' : data.accurate.length}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,7 +165,7 @@ export default function DiscrepancyDashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 overflow-x-auto pb-1 md:pb-0 scrollbar-hide">
|
<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
|
<button
|
||||||
key={f}
|
key={f}
|
||||||
onClick={() => setFilter(f)}
|
onClick={() => setFilter(f)}
|
||||||
@@ -145,45 +175,60 @@ export default function DiscrepancyDashboard() {
|
|||||||
: 'bg-white text-gray-500 border-gray-200 hover:bg-gray-50'
|
: 'bg-white text-gray-500 border-gray-200 hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{f === 'all' ? 'همه موارد' : f === 'surplus' ? 'مازاد (اضافه)' : 'کسری'}
|
{f === 'all' ? 'همه مغایرتها' : f === 'surplus' ? 'مازاد' : f === 'deficit' ? 'کسری' : 'شمارشنشده'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Discrepancies List (Card Based) */}
|
{/* Discrepancies / Uncounted List */}
|
||||||
<div className="flex flex-col gap-4 mt-2">
|
<div className="flex flex-col gap-4 mt-2">
|
||||||
<div className="flex items-center gap-2 px-2">
|
<div className="flex items-center gap-2 px-2">
|
||||||
<PackageSearch className="text-red-500" size={20} strokeWidth={2.5} />
|
<PackageSearch className={isUncountedMode ? "text-orange-500" : "text-red-500"} size={20} strokeWidth={2.5} />
|
||||||
<h3 className="font-black text-gray-800 text-sm">لیست کالاهای نیازمند بررسی</h3>
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<AnimatePresence>
|
<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
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
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"
|
className="bg-white rounded-[20px] shadow-sm border border-gray-100 p-5 flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
{/* Top: Product Info & Badge */}
|
{/* Top: Product Info & Badge */}
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div className="flex flex-col pr-1">
|
<div className="flex flex-col pr-1">
|
||||||
<h4 className="font-bold text-gray-800 text-sm leading-snug">{item.product_name}</h4>
|
<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">کد حسابفا: {item.product_id}</p>
|
<p className="text-[10px] text-gray-400 mt-1 font-medium tracking-wider">کد حسابفا: {pId}</p>
|
||||||
</div>
|
</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'}`}>
|
<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} />}
|
{item.difference > 0 ? <ArrowUpRight size={14} strokeWidth={3} /> : <ArrowDownRight size={14} strokeWidth={3} />}
|
||||||
{Math.abs(item.difference)}
|
{Math.abs(item.difference)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Middle: Stats */}
|
{/* Middle: Stats */}
|
||||||
|
{!isUncounted && (
|
||||||
<div className="flex items-center bg-gray-50 rounded-[16px] p-3 border border-gray-100">
|
<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">
|
<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-[10px] font-bold text-gray-400 mb-0.5">موجودی سیستم</span>
|
||||||
@@ -196,29 +241,33 @@ export default function DiscrepancyDashboard() {
|
|||||||
<div className="flex-1 flex flex-col items-center">
|
<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-[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">
|
<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('، ') : '-'}
|
{item.locations?.length > 0 ? item.locations.join('، ') : '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bottom: Actions */}
|
{/* Bottom: Actions */}
|
||||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-gray-50 mt-1">
|
<div className="flex items-center justify-end gap-2 pt-2 border-t border-gray-50 mt-1">
|
||||||
<button
|
<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"
|
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>
|
||||||
|
{!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">
|
<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} />
|
<Send size={14} />
|
||||||
ارسال تعدیل
|
ارسال تعدیل
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</AnimatePresence>
|
</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="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">
|
<div className="w-16 h-16 bg-green-50 text-green-500 rounded-full flex items-center justify-center">
|
||||||
<CheckCircle size={32} />
|
<CheckCircle size={32} />
|
||||||
@@ -231,7 +280,7 @@ export default function DiscrepancyDashboard() {
|
|||||||
{loading && (
|
{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="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>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,21 +3,26 @@ import { useState, useEffect } from 'react';
|
|||||||
import Header from '@/components/Header';
|
import Header from '@/components/Header';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 });
|
const Scanner = dynamic(() => import('@yudiel/react-qr-scanner').then(mod => mod.Scanner), { ssr: false });
|
||||||
|
|
||||||
export default function AdminLocations() {
|
export default function AdminLocations() {
|
||||||
const [locations, setLocations] = useState([]);
|
const [locations, setLocations] = useState([]);
|
||||||
const [warehouses, setWarehouses] = useState([]);
|
const [warehouses, setWarehouses] = useState([]);
|
||||||
const [selectedWarehouse, setSelectedWarehouse] = 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 [loading, setLoading] = useState(false);
|
||||||
const [cameraEnabled, setCameraEnabled] = useState(false);
|
const [cameraEnabled, setCameraEnabled] = useState(true);
|
||||||
const [camError, setCamError] = useState('');
|
const [camError, setCamError] = useState('');
|
||||||
const [activeFilter, setActiveFilter] = useState('all');
|
const [activeFilter, setActiveFilter] = useState('all');
|
||||||
|
|
||||||
const [editingId, setEditingId] = useState(null);
|
const [editingId, setEditingId] = useState(null);
|
||||||
const [editCode, setEditCode] = useState('');
|
|
||||||
|
|
||||||
const [toast, setToast] = useState({ show: false, message: '', isError: false });
|
const [toast, setToast] = useState({ show: false, message: '', isError: false });
|
||||||
|
|
||||||
@@ -57,14 +62,37 @@ export default function AdminLocations() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddOrEdit = async (codeValue) => {
|
// Smart Parser for the main code input
|
||||||
const targetCode = editingId ? editCode : (codeValue || newCode);
|
const handleFullCodeChange = (e) => {
|
||||||
if (!targetCode) return;
|
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) {
|
if (!selectedWarehouse) {
|
||||||
showToast('لطفاً ابتدا انبار را انتخاب کنید', true);
|
showToast('لطفاً ابتدا انبار را انتخاب کنید', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const generatedCode = `${floor.toUpperCase()}${region}${sector.toUpperCase()}${row}`;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const method = editingId ? 'PUT' : 'POST';
|
const method = editingId ? 'PUT' : 'POST';
|
||||||
@@ -73,15 +101,14 @@ export default function AdminLocations() {
|
|||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ code: targetCode, warehouse: selectedWarehouse })
|
body: JSON.stringify({ code: generatedCode, warehouse: selectedWarehouse })
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast(editingId ? 'قفسه با موفقیت ویرایش شد' : 'قفسه با موفقیت ثبت شد!');
|
showToast(editingId ? 'قفسه با موفقیت ویرایش شد' : 'قفسه با موفقیت ثبت شد!');
|
||||||
setNewCode('');
|
setFloor(''); setRegion(''); setSector(''); setRow('');
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setEditCode('');
|
|
||||||
fetchLocations();
|
fetchLocations();
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'خطا در ثبت قفسه', true);
|
showToast(data.error || 'خطا در ثبت قفسه', true);
|
||||||
@@ -112,8 +139,20 @@ export default function AdminLocations() {
|
|||||||
|
|
||||||
const handleScan = (detectedCodes) => {
|
const handleScan = (detectedCodes) => {
|
||||||
if (detectedCodes && detectedCodes.length > 0) {
|
if (detectedCodes && detectedCodes.length > 0) {
|
||||||
const scannedValue = detectedCodes[0].rawValue;
|
const scannedValue = detectedCodes[0].rawValue.toUpperCase();
|
||||||
handleAddOrEdit(scannedValue);
|
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);
|
filteredLocations = filteredLocations.filter(loc => loc.floor === activeFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const generatedCodePreview = (floor || region || sector || row) ? `${floor.toUpperCase()}${region}${sector.toUpperCase()}${row}` : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full min-h-screen bg-gray-50 flex flex-col pb-24 relative">
|
<div className="w-full min-h-screen bg-gray-50 flex flex-col pb-24 relative">
|
||||||
<Header title="مدیریت قفسهها" showBack={true} />
|
<Header title="مدیریت قفسهها" showBack={true} />
|
||||||
@@ -148,7 +189,7 @@ export default function AdminLocations() {
|
|||||||
<div className="flex justify-between items-center mb-1">
|
<div className="flex justify-between items-center mb-1">
|
||||||
<span className="text-sm font-bold text-gray-700">{editingId ? 'ویرایش قفسه' : 'ثبت قفسه جدید'}</span>
|
<span className="text-sm font-bold text-gray-700">{editingId ? 'ویرایش قفسه' : 'ثبت قفسه جدید'}</span>
|
||||||
{editingId && (
|
{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>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -165,25 +206,47 @@ export default function AdminLocations() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
value={editingId ? editCode : newCode}
|
onChange={handleFullCodeChange}
|
||||||
onChange={(e) => editingId ? setEditCode(e.target.value) : setNewCode(e.target.value)}
|
placeholder="C2F3"
|
||||||
placeholder="مثال: C2F2"
|
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"
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
<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>
|
||||||
|
|
||||||
|
<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]">
|
<div className="relative overflow-hidden rounded-[16px]">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{cameraEnabled && (
|
{cameraEnabled && (
|
||||||
@@ -214,7 +277,7 @@ export default function AdminLocations() {
|
|||||||
<motion.button
|
<motion.button
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
onClick={() => setCameraEnabled(true)}
|
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} />
|
<ScanLine size={18} strokeWidth={2.5} />
|
||||||
اسکن بارکد قفسه
|
اسکن بارکد قفسه
|
||||||
@@ -269,7 +332,7 @@ export default function AdminLocations() {
|
|||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-black text-lg text-gray-800 tracking-wide uppercase">{loc.code}</span>
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -278,7 +341,14 @@ export default function AdminLocations() {
|
|||||||
{!hasData ? (
|
{!hasData ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<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"
|
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} />
|
<Edit2 size={16} strokeWidth={2.5} />
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, use } from 'react';
|
||||||
import Header from '@/components/Header';
|
import Header from '@/components/Header';
|
||||||
import { use } from 'react';
|
import { User as UserIcon, Calendar, Package, Layers, CheckCircle2, AlertTriangle, ArrowLeft } from 'lucide-react';
|
||||||
import { User as UserIcon, Calendar, Package, ArrowRight, ShieldCheck } from 'lucide-react';
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
export default function UserProfilePage({ params }) {
|
export default function UserProfilePage({ params }) {
|
||||||
const unwrappedParams = use(params);
|
const unwrappedParams = use(params);
|
||||||
@@ -16,11 +16,9 @@ export default function UserProfilePage({ params }) {
|
|||||||
|
|
||||||
const fetchUserData = async () => {
|
const fetchUserData = async () => {
|
||||||
try {
|
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([
|
const [usersRes, countingsRes] = await Promise.all([
|
||||||
fetch('/api/users'),
|
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) {
|
if (usersRes.ok) {
|
||||||
@@ -29,12 +27,9 @@ export default function UserProfilePage({ params }) {
|
|||||||
setUser(found);
|
setUser(found);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Since /api/reports/countings might not support userId yet,
|
|
||||||
// let's just pretend we have it or gracefully fallback.
|
|
||||||
if (countingsRes.ok) {
|
if (countingsRes.ok) {
|
||||||
const data = await countingsRes.json();
|
const data = await countingsRes.json();
|
||||||
// Fallback for demo if API doesn't support filter
|
setCountings(data);
|
||||||
setCountings(data.filter ? data.filter(c => c.userId === parseInt(unwrappedParams.id)) : []);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(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 (
|
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} />
|
<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">
|
<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="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="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">
|
<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.name ? user.name.charAt(0) : '?'}
|
{user.avatarUrl ? <img src={user.avatarUrl} className="w-full h-full object-cover" alt="" /> : (user.name ? user.name.charAt(0) : '?')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="text-xl font-black text-gray-800 z-10">{user.name || 'کاربر بدون نام'}</h2>
|
<h2 className="text-xl font-black text-gray-800 z-10">{user.name || 'کاربر بدون نام'}</h2>
|
||||||
@@ -92,25 +96,109 @@ export default function UserProfilePage({ params }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<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">
|
<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">
|
||||||
<div className="w-10 h-10 bg-blue-50 text-blue-500 rounded-xl flex items-center justify-center">
|
<Package size={24} className="text-indigo-500 mb-1" strokeWidth={2} />
|
||||||
<Package size={20} strokeWidth={2.5} />
|
<span className="text-[10px] font-bold text-gray-400">مجموع شمارش</span>
|
||||||
|
<span className="text-xl font-black text-gray-800">{countings.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-bold text-gray-400 mt-2">مجموع شمارشها</span>
|
<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">
|
||||||
<span className="text-2xl font-black text-gray-800">{user._count?.countings || 0}</span>
|
<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>
|
||||||
<div className="bg-white rounded-[20px] p-5 shadow-sm border border-gray-100 flex flex-col gap-2">
|
<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">
|
||||||
<div className="w-10 h-10 bg-green-50 text-green-500 rounded-xl flex items-center justify-center">
|
<AlertTriangle size={24} className="text-red-500 mb-1" strokeWidth={2} />
|
||||||
<Calendar size={20} strokeWidth={2.5} />
|
<span className="text-[10px] font-bold text-gray-400">مغایرتها</span>
|
||||||
|
<span className="text-xl font-black text-red-600">{discrepancies}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-bold text-gray-400 mt-2">تاریخ عضویت</span>
|
<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">
|
||||||
<span className="text-sm font-black text-gray-800 mt-1" dir="ltr">
|
<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')}
|
{new Date(user.createdAt).toLocaleDateString('fa-IR')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,17 +37,19 @@ export async function POST(req) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 2. Unlock the location
|
// 2. Unlock the location
|
||||||
|
if (shelfCode) {
|
||||||
await prisma.location.updateMany({
|
await prisma.location.updateMany({
|
||||||
where: { code: shelfCode.toUpperCase(), warehouse: Number(warehouse) },
|
where: { code: shelfCode.toUpperCase(), warehouse: Number(warehouse) },
|
||||||
data: { isLocked: false, lockedById: null, lockedAt: null }
|
data: { isLocked: false, lockedById: null, lockedAt: null }
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Log the action
|
// 3. Log the action
|
||||||
await prisma.actionLog.create({
|
await prisma.actionLog.create({
|
||||||
data: {
|
data: {
|
||||||
userId: Number(userId),
|
userId: Number(userId),
|
||||||
action: 'CANCEL_COUNTING',
|
action: 'CANCEL_COUNTING',
|
||||||
details: `لغو شمارش ${mode === 'SHELF' ? 'قفسه' : 'کالا'} ${shelfCode.toUpperCase()} به دلیل: ${reason}`
|
details: `لغو شمارش ${mode === 'SHELF' ? 'قفسه' : 'کالا'} ${shelfCode ? shelfCode.toUpperCase() : product_id} به دلیل: ${reason}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,48 @@
|
|||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
import { NextResponse } from 'next/server';
|
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) {
|
export async function GET(req) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const warehouse = searchParams.get('warehouse') || '11';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(req.url);
|
// 1. Fetch countings
|
||||||
const thresholdDays = parseInt(url.searchParams.get('days')) || 10;
|
const countings = await prisma.counting.findMany({
|
||||||
|
where: { warehouse: Number(warehouse), status: { not: 'CANCELLED' } }
|
||||||
// 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
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get the most recent counting date for each location
|
// Get distinct product IDs that were counted
|
||||||
// Prisma group by or finding max date per shelf
|
const countedProductIds = new Set(countings.map(c => Number(c.product_id)));
|
||||||
const latestCounts = await prisma.counting.groupBy({
|
|
||||||
by: ['shelfCode'],
|
// 2. Fetch all products from Hesabfa
|
||||||
_max: {
|
const res = await axios.post('https://api.hesabfa.com/v1/item/getitems', {
|
||||||
createdAt: true
|
apiKey: HESABFA_API_KEY,
|
||||||
}
|
loginToken: HESABFA_TOKEN,
|
||||||
|
queryInfo: { Take: 2000, Skip: 0 },
|
||||||
|
type: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
const countMap = new Map();
|
const hesabfaItems = res.data?.Result?.List || [];
|
||||||
latestCounts.forEach(c => {
|
|
||||||
countMap.set(c.shelfCode, c._max.createdAt);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter locations that have either NEVER been counted, or the latest count is older than cutoff
|
// 3. Filter items that have Stock > 0 but are not in countedProductIds
|
||||||
const uncounted = locations.filter(loc => {
|
const uncounted = hesabfaItems.filter(item => {
|
||||||
const lastCountDate = countMap.get(loc.code);
|
// Check if not counted
|
||||||
if (!lastCountDate) return true; // Never counted
|
if (countedProductIds.has(item.Code)) return false;
|
||||||
return new Date(lastCountDate) < cutoffDate;
|
|
||||||
|
// 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 });
|
return NextResponse.json({ uncounted });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch uncounted error:', error);
|
console.error('Uncounted Error:', error);
|
||||||
return NextResponse.json({ error: 'خطا در دریافت لیست قفسهها' }, { status: 500 });
|
return NextResponse.json({ error: 'خطا در محاسبه کالاهای شمارش نشده' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function ItemCountingContent() {
|
|||||||
const [submitLoading, setSubmitLoading] = useState(false);
|
const [submitLoading, setSubmitLoading] = useState(false);
|
||||||
const [history, setHistory] = useState([]);
|
const [history, setHistory] = useState([]);
|
||||||
|
|
||||||
const [cameraEnabled, setCameraEnabled] = useState(false);
|
const [cameraEnabled, setCameraEnabled] = useState(true);
|
||||||
const [camError, setCamError] = useState('');
|
const [camError, setCamError] = useState('');
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function ShelfCountingContent() {
|
|||||||
const [submitLoading, setSubmitLoading] = useState(false);
|
const [submitLoading, setSubmitLoading] = useState(false);
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
|
||||||
const [cameraEnabled, setCameraEnabled] = useState(false);
|
const [cameraEnabled, setCameraEnabled] = useState(true);
|
||||||
const [camError, setCamError] = useState('');
|
const [camError, setCamError] = useState('');
|
||||||
|
|
||||||
// History of scanned items in this session
|
// History of scanned items in this session
|
||||||
|
|||||||
+46
-19
@@ -29,36 +29,36 @@ export default function Header({ title = 'داشبورد', showBack = false }) {
|
|||||||
return name.charAt(0);
|
return name.charAt(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!showBack) {
|
||||||
return (
|
return (
|
||||||
<motion.header
|
<motion.header
|
||||||
initial={{ y: -20, opacity: 0 }}
|
initial={{ y: -50, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
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)]"
|
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)]"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<Link href="/settings" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
||||||
{showBack ? (
|
<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">
|
||||||
<button
|
{user?.avatarUrl ? (
|
||||||
onClick={() => router.back()}
|
<img src={user.avatarUrl} alt={user.name} className="w-full h-full object-cover" />
|
||||||
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)
|
||||||
{getInitial(user?.name)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col pr-1">
|
||||||
<div className="font-extrabold text-sm text-gray-800 absolute left-1/2 -translate-x-1/2 whitespace-nowrap">
|
<span className="text-[10px] text-gray-400 font-extrabold tracking-wider mb-0.5">پردیس رایانه</span>
|
||||||
{title}
|
<span className="text-sm font-black text-gray-800 tracking-tight">
|
||||||
|
{user?.name || 'کاربر'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-2 pl-1">
|
||||||
{!showBack && user?.roles?.includes('ADMIN') && (
|
{user?.roles?.includes('ADMIN') && (
|
||||||
<Link
|
<Link
|
||||||
href="/admin"
|
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"
|
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} />
|
<Settings size={18} strokeWidth={2.5} />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -66,10 +66,37 @@ export default function Header({ title = 'داشبورد', showBack = false }) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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"
|
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={16} strokeWidth={2.5} />
|
<LogOut size={18} strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.header>
|
</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)]"
|
||||||
|
>
|
||||||
|
<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="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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user