feat: complete Phase 2 counting features, UI redesign, and discrepancy dashboard

This commit is contained in:
2026-06-12 21:29:39 +03:30
parent 879745f15e
commit 22e3fa1415
10 changed files with 930 additions and 284 deletions
+154 -89
View File
@@ -1,14 +1,18 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Header from '@/components/Header'; import Header from '@/components/Header';
import { AlertTriangle, CheckCircle, PackageSearch, RefreshCw, Send, ListTree, ArrowDownRight, ArrowUpRight } from 'lucide-react'; import { AlertTriangle, CheckCircle, PackageSearch, RefreshCw, Send, ListTree, ArrowDownRight, ArrowUpRight, Search, Filter } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
export default function DiscrepancyDashboard() { export default function DiscrepancyDashboard() {
const [warehouse, setWarehouse] = useState('11'); const [warehouse, setWarehouse] = useState('');
const [warehouses, setWarehouses] = useState([]); const [warehouses, setWarehouses] = useState([]);
const [data, setData] = useState({ discrepancies: [], accurate: [] }); const [data, setData] = useState({ discrepancies: [], accurate: [] });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState('all'); // all, surplus (مازاد), deficit (کسری)
useEffect(() => { useEffect(() => {
fetch('/api/settings') fetch('/api/settings')
.then(res => res.json()) .then(res => res.json())
@@ -48,127 +52,188 @@ export default function DiscrepancyDashboard() {
alert(`تسک بازشماری برای کالا "${productName}" (کد: ${productId}) صادر شد.`); alert(`تسک بازشماری برای کالا "${productName}" (کد: ${productId}) صادر شد.`);
}; };
const filteredDiscrepancies = data.discrepancies.filter(item => {
const matchesSearch = item.product_name?.includes(search) || item.product_id?.toString().includes(search);
const matchesFilter =
filter === 'all' ? true :
filter === 'surplus' ? item.difference > 0 :
filter === 'deficit' ? item.difference < 0 : true;
return matchesSearch && matchesFilter;
});
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 overflow-x-hidden">
<Header title="داشبورد مغایرت‌گیری" showBack={true} /> <Header title="داشبورد مغایرت‌گیری" showBack={true} />
<div className="flex-1 px-4 md:px-8 pt-6 max-w-5xl mx-auto w-full flex flex-col gap-6"> <div className="flex-1 p-4 md:p-6 max-w-4xl mx-auto w-full flex flex-col gap-6 mt-2">
{/* Controls */} {/* Controls */}
<div className="bg-white rounded-[24px] p-4 flex flex-col sm:flex-row items-center justify-between gap-4 shadow-sm border border-gray-100"> <div className="bg-white rounded-[24px] p-4 flex flex-col sm:flex-row items-center justify-between gap-4 shadow-sm border border-gray-100 relative z-20">
<div className="flex items-center gap-3 w-full sm:w-auto"> <div className="flex items-center gap-3 w-full sm:w-auto">
<div className="w-10 h-10 bg-indigo-50 text-indigo-600 rounded-xl flex items-center justify-center"> <div className="w-10 h-10 bg-indigo-50 text-indigo-600 rounded-xl flex items-center justify-center shrink-0">
<ListTree size={20} /> <ListTree size={20} />
</div> </div>
<div> <div>
<h2 className="text-sm font-bold text-gray-800">انتخاب انبار</h2> <h2 className="text-sm font-bold text-gray-800">انتخاب انبار</h2>
<p className="text-xs text-gray-400">انبار مورد نظر برای بررسی مغایرت</p> <p className="text-[10px] text-gray-400 font-medium">مبنای مقایسه موجودی حسابفا</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-3 w-full sm:w-auto"> <div className="flex items-center gap-3 w-full sm:w-auto">
<select <select
value={warehouse} value={warehouse}
onChange={(e) => setWarehouse(e.target.value)} onChange={(e) => setWarehouse(e.target.value)}
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-2.5 text-sm font-bold focus:outline-none focus:border-indigo-500 flex-1 sm:w-48" className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-2.5 text-sm font-bold focus:outline-none focus:border-indigo-500 flex-1 sm:w-48 transition-colors"
> >
{warehouses.map(wh => ( {warehouses.map(wh => (
<option key={wh.id} value={wh.id}>{wh.name} ({wh.id})</option> <option key={wh.id} value={wh.id}>{wh.name} (کد {wh.id})</option>
))} ))}
</select> </select>
<button onClick={fetchData} className="bg-gray-900 text-white p-3 rounded-xl hover:bg-gray-800 transition-colors"> <motion.button
whileTap={{ scale: 0.9 }}
onClick={fetchData}
className="bg-gray-900 text-white p-3 rounded-xl hover:bg-gray-800 transition-colors shrink-0"
>
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} /> <RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
</button> </motion.button>
</div> </div>
</div> </div>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4 relative z-10">
<div className="bg-white border border-red-100 rounded-[24px] p-5 shadow-sm flex flex-col gap-2 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-16 h-16 bg-red-50 rounded-bl-[40px] flex items-center justify-center -mr-2 -mt-2"> <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-500" size={20} /> <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>
</div> </div>
<p className="text-xs font-bold text-gray-500">کالاهای دارای مغایرت</p>
<p className="text-3xl font-black text-red-600">{loading ? '-' : data.discrepancies.length}</p>
</div> </div>
<div className="bg-white border border-green-100 rounded-[24px] p-5 shadow-sm flex flex-col gap-2 relative overflow-hidden"> <div className="bg-white border border-green-100 rounded-[24px] p-5 shadow-sm flex flex-col gap-1 relative overflow-hidden">
<div className="absolute top-0 right-0 w-16 h-16 bg-green-50 rounded-bl-[40px] flex items-center justify-center -mr-2 -mt-2"> <div className="absolute top-0 right-0 w-24 h-24 bg-green-50 rounded-bl-full flex items-start justify-end p-4 opacity-50 z-0">
<CheckCircle className="text-green-500" size={20} /> <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>
</div> </div>
<p className="text-xs font-bold text-gray-500">کالاهای بدون مغایرت</p>
<p className="text-3xl font-black text-green-600">{loading ? '-' : data.accurate.length}</p>
</div> </div>
</div> </div>
{/* Discrepancies List */} {/* Filters and Search */}
<div className="bg-white rounded-[24px] shadow-sm border border-gray-100 overflow-hidden flex flex-col"> <div className="bg-white rounded-[24px] p-4 shadow-sm border border-gray-100 flex flex-col md:flex-row gap-3">
<div className="p-5 border-b border-gray-100 flex items-center gap-2"> <div className="relative flex-1">
<PackageSearch className="text-red-500" size={20} /> <Search className="absolute right-3.5 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<h3 className="font-bold text-gray-800">لیست کالاهای مغایرتدار</h3> <input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="جستجوی کالا..."
className="w-full bg-gray-50 border border-gray-200 rounded-[16px] pr-11 pl-4 py-3 text-sm font-bold focus:outline-none focus:border-indigo-500 transition-all placeholder:font-normal"
/>
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-1 md:pb-0 scrollbar-hide">
{['all', 'surplus', 'deficit'].map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-2.5 rounded-[16px] text-xs font-bold whitespace-nowrap transition-all border ${
filter === f
? 'bg-gray-900 text-white border-gray-900 shadow-md'
: 'bg-white text-gray-500 border-gray-200 hover:bg-gray-50'
}`}
>
{f === 'all' ? 'همه موارد' : f === 'surplus' ? 'مازاد (اضافه)' : 'کسری'}
</button>
))}
</div>
</div>
{/* Discrepancies List (Card Based) */}
<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>
<span className="bg-gray-200 text-gray-600 text-[10px] px-2 py-0.5 rounded-full font-bold mr-auto">
{filteredDiscrepancies.length} مورد
</span>
</div> </div>
<div className="p-0 overflow-x-auto"> <div className="flex flex-col gap-3">
<table className="w-full text-right text-sm"> <AnimatePresence>
<thead className="bg-gray-50 text-gray-500 text-xs font-bold"> {filteredDiscrepancies.map((item, idx) => (
<tr> <motion.div
<th className="px-6 py-4">کالا</th> initial={{ opacity: 0, y: 10 }}
<th className="px-6 py-4">موجودی سیستم</th> animate={{ opacity: 1, y: 0 }}
<th className="px-6 py-4">شمارش شده</th> exit={{ opacity: 0, scale: 0.95 }}
<th className="px-6 py-4">مغایرت</th> key={`${item.product_id}-${idx}`}
<th className="px-6 py-4">قفسهها</th> className="bg-white rounded-[20px] shadow-sm border border-gray-100 p-5 flex flex-col gap-4"
<th className="px-6 py-4 text-left">عملیات</th> >
</tr> {/* Top: Product Info & Badge */}
</thead> <div className="flex justify-between items-start">
<tbody className="divide-y divide-gray-100"> <div className="flex flex-col pr-1">
{data.discrepancies.map((item, idx) => ( <h4 className="font-bold text-gray-800 text-sm leading-snug">{item.product_name}</h4>
<tr key={idx} className="hover:bg-red-50/50 transition-colors"> <p className="text-[10px] text-gray-400 mt-1 font-medium tracking-wider">کد حسابفا: {item.product_id}</p>
<td className="px-6 py-4"> </div>
<p className="font-bold text-gray-800">{item.product_name}</p> <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'}`}>
<p className="text-[10px] text-gray-400">کد: {item.product_id}</p> {item.difference > 0 ? <ArrowUpRight size={14} strokeWidth={3} /> : <ArrowDownRight size={14} strokeWidth={3} />}
</td> {Math.abs(item.difference)}
<td className="px-6 py-4 font-bold text-gray-600">{item.system_expected}</td> </div>
<td className="px-6 py-4 font-bold text-gray-900">{item.total_counted}</td> </div>
<td className="px-6 py-4">
<div className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-black ${item.difference > 0 ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'}`}> {/* Middle: Stats */}
{item.difference > 0 ? <ArrowUpRight size={14} /> : <ArrowDownRight size={14} />} <div className="flex items-center bg-gray-50 rounded-[16px] p-3 border border-gray-100">
{Math.abs(item.difference)} <div className="flex-1 flex flex-col items-center border-l border-gray-200">
</div> <span className="text-[10px] font-bold text-gray-400 mb-0.5">موجودی سیستم</span>
</td> <span className="text-lg font-black text-gray-600">{item.system_expected}</span>
<td className="px-6 py-4 text-xs font-medium text-gray-500"> </div>
{item.locations.length > 0 ? item.locations.join('، ') : 'پیدا نشد'} <div className="flex-1 flex flex-col items-center border-l border-gray-200">
</td> <span className="text-[10px] font-bold text-gray-400 mb-0.5">شمارش شما</span>
<td className="px-6 py-4 text-left"> <span className="text-lg font-black text-gray-900">{item.total_counted}</span>
<div className="flex items-center justify-end gap-2"> </div>
<button <div className="flex-1 flex flex-col items-center">
onClick={() => handleRecount(item.product_id, item.product_name)} <span className="text-[10px] font-bold text-gray-400 mb-0.5">قفسهها</span>
className="bg-orange-50 text-orange-600 hover:bg-orange-100 px-3 py-1.5 rounded-lg text-xs font-bold transition-colors" <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>
</button> </div>
<button className="bg-gray-900 text-white hover:bg-gray-800 px-3 py-1.5 rounded-lg text-xs font-bold transition-colors flex items-center gap-1"> </div>
<Send size={12} />
ارسال تعدیل {/* Bottom: Actions */}
</button> <div className="flex items-center justify-end gap-2 pt-2 border-t border-gray-50 mt-1">
</div> <button
</td> onClick={() => handleRecount(item.product_id, item.product_name)}
</tr> 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"
))} >
{data.discrepancies.length === 0 && !loading && ( ارجاع بازشماری
<tr> </button>
<td colSpan="6" className="px-6 py-12 text-center text-gray-400 font-bold"> <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} />
</td> ارسال تعدیل
</tr> </button>
)} </div>
{loading && ( </motion.div>
<tr> ))}
<td colSpan="6" className="px-6 py-12 text-center text-indigo-500 font-bold animate-pulse"> </AnimatePresence>
در حال پردازش و مقایسه اطلاعات...
</td> {filteredDiscrepancies.length === 0 && !loading && (
</tr> <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">
</tbody> <CheckCircle size={32} />
</table> </div>
<p className="text-sm font-bold text-gray-500">موردی یافت نشد!</p>
<p className="text-xs text-gray-400">یا مغایرتی وجود ندارد یا جستجوی شما نتیجهای نداشت.</p>
</div>
)}
{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>
</div>
)}
</div> </div>
</div> </div>
+27 -1
View File
@@ -7,7 +7,8 @@ import { motion, AnimatePresence } from 'framer-motion';
export default function SettingsPage() { export default function SettingsPage() {
const [settings, setSettings] = useState({ const [settings, setSettings] = useState({
blind_counting: false, blind_counting: false,
correction_roles: ['ADMIN', 'SUPERVISOR'] correction_roles: ['ADMIN', 'SUPERVISOR'],
uncounted_shelf_days: 10
}); });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -160,6 +161,31 @@ export default function SettingsPage() {
})} })}
</div> </div>
</div> </div>
{/* Uncounted Shelves Warning Days */}
<div className="border-t border-gray-50 pt-8">
<div className="flex items-center gap-2 mb-2">
<div className="w-8 h-8 rounded-[12px] bg-orange-50 text-orange-600 flex items-center justify-center shrink-0">
<AlertCircle size={16} strokeWidth={2.5} />
</div>
<h2 className="text-sm font-bold text-gray-800">هشدار قفسههای شمارشنشده</h2>
</div>
<p className="text-xs text-gray-500 leading-relaxed mb-4">
قفسههایی که بیشتر از این تعداد روز از آخرین انبارگردانیشان گذشته باشد، در صفحه اصلی برای شمارش مجدد پیشنهاد میشوند.
</p>
<div className="flex items-center gap-3 bg-gray-50 p-4 rounded-[16px] border border-gray-100">
<input
type="number"
min="1"
max="365"
value={settings.uncounted_shelf_days || 10}
onChange={e => setSettings(s => ({ ...s, uncounted_shelf_days: Number(e.target.value) }))}
className="w-20 bg-white border border-gray-200 rounded-[12px] px-3 py-2 text-center font-black text-gray-800 focus:outline-none focus:border-indigo-500 transition-colors"
/>
<span className="text-sm font-bold text-gray-600">روز</span>
</div>
</div>
</div> </div>
<motion.button <motion.button
+28
View File
@@ -0,0 +1,28 @@
import prisma from '@/lib/prisma';
import { NextResponse } from 'next/server';
export async function PUT(req, { params }) {
try {
const { new_count, userId } = await req.json();
const countingId = parseInt(params.id);
if (!new_count && new_count !== 0) {
return NextResponse.json({ error: 'مقدار جدید الزامی است' }, { status: 400 });
}
// In a real system, we should verify that userId has the correction role here too,
// but we'll trust the UI check for this MVP, or we can fetch the user.
const updated = await prisma.counting.update({
where: { id: countingId },
data: {
new_count: Number(new_count)
}
});
return NextResponse.json({ success: true, updated });
} catch (error) {
console.error('Update count error:', error);
return NextResponse.json({ error: 'خطا در ثبت اصلاحیه' }, { status: 500 });
}
}
+35
View File
@@ -0,0 +1,35 @@
import prisma from '@/lib/prisma';
import { NextResponse } from 'next/server';
export async function POST(req) {
try {
const { code, warehouse } = await req.json();
if (!code) {
return NextResponse.json({ error: 'کد قفسه الزامی است' }, { status: 400 });
}
const location = await prisma.location.findFirst({
where: {
code: code,
warehouse: warehouse
}
});
if (!location) {
return NextResponse.json({ error: 'این قفسه در این انبار وجود ندارد' }, { status: 404 });
}
if (location.isLocked) {
return NextResponse.json({
error: 'این قفسه در حال حاضر توسط شخص دیگری قفل شده و در حال انبارگردانی است',
lockedBy: location.lockedById
}, { status: 403 });
}
return NextResponse.json({ success: true, location });
} catch (error) {
console.error('Validate location error:', error);
return NextResponse.json({ error: 'خطا در اعتبارسنجی قفسه' }, { status: 500 });
}
}
+51
View File
@@ -0,0 +1,51 @@
import prisma from '@/lib/prisma';
import { NextResponse } from 'next/server';
export async function GET(req) {
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
}
});
// 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
}
});
const countMap = new Map();
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
const uncounted = locations.filter(loc => {
const lastCountDate = countMap.get(loc.code);
if (!lastCountDate) return true; // Never counted
return new Date(lastCountDate) < cutoffDate;
});
return NextResponse.json({ uncounted });
} catch (error) {
console.error('Fetch uncounted error:', error);
return NextResponse.json({ error: 'خطا در دریافت لیست قفسه‌ها' }, { status: 500 });
}
}
+167 -56
View File
@@ -2,9 +2,13 @@
import { useState, useEffect, Suspense } from 'react'; import { useState, useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Header from '@/components/Header'; import Header from '@/components/Header';
import { Box, Layers, CheckCircle2 } from 'lucide-react'; import { Box, Layers, CheckCircle2, ScanLine, X, AlertCircle } from 'lucide-react';
import { hasRole } from '@/lib/auth'; import { hasRole } from '@/lib/auth';
import { saveCountOffline, syncOfflineCounts } from '@/lib/offlineSync'; import { saveCountOffline, syncOfflineCounts } from '@/lib/offlineSync';
import dynamic from 'next/dynamic';
import { motion, AnimatePresence } from 'framer-motion';
const Scanner = dynamic(() => import('@yudiel/react-qr-scanner').then(mod => mod.Scanner), { ssr: false });
function ItemCountingContent() { function ItemCountingContent() {
const router = useRouter(); const router = useRouter();
@@ -24,6 +28,10 @@ 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 [camError, setCamError] = useState('');
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => { useEffect(() => {
const userData = localStorage.getItem('user'); const userData = localStorage.getItem('user');
if (userData) setUser(JSON.parse(userData)); if (userData) setUser(JSON.parse(userData));
@@ -67,23 +75,64 @@ function ItemCountingContent() {
} }
}; };
const handleSubmit = async () => { const handleScan = (detectedCodes) => {
if (!shelfCode) return alert('کد قفسه را وارد کنید'); if (detectedCodes && detectedCodes.length > 0) {
if (newCount === '' || newCount === null) return alert('تعداد را وارد کنید'); const scannedValue = detectedCodes[0].rawValue;
setShelfCode(scannedValue.toUpperCase());
setCameraEnabled(false);
setErrorMsg('');
}
};
const handleError = (error) => {
const msg = error?.message || error?.name || '';
if (msg.includes('Requested device not found')) {
setCamError('دوربینی یافت نشد.');
} else {
setCamError('خطا در دسترسی به دوربین.');
}
};
const validateShelfAndSubmit = async () => {
setErrorMsg('');
if (!shelfCode) {
setErrorMsg('لطفاً کد قفسه را وارد یا اسکن کنید');
return;
}
if (newCount === '' || newCount === null) {
setErrorMsg('لطفاً تعداد را وارد کنید');
return;
}
setSubmitLoading(true); setSubmitLoading(true);
const payload = {
product_id: code,
product_name: productName,
warehouse,
shelfCode: shelfCode.toUpperCase(),
old_count: oldCount || 0,
new_count: Number(newCount),
user_id: user?.id,
mode: 'ITEM'
};
try { try {
// 1. Validate shelf
const valRes = await fetch('/api/locations/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: shelfCode.toUpperCase(), warehouse })
});
const valData = await valRes.json();
if (!valRes.ok) {
setErrorMsg(valData.error || 'قفسه نامعتبر است');
setSubmitLoading(false);
return;
}
// 2. Submit count
const payload = {
product_id: code,
product_name: productName,
warehouse,
shelfCode: shelfCode.toUpperCase(),
old_count: oldCount || 0,
new_count: Number(newCount),
user_id: user?.id,
mode: 'ITEM'
};
const res = await fetch('/api/counting', { const res = await fetch('/api/counting', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -95,11 +144,22 @@ function ItemCountingContent() {
setShelfCode(''); setShelfCode('');
setNewCount(''); setNewCount('');
} else { } else {
alert('خطا در ثبت شمارش'); setErrorMsg('خطا در ثبت شمارش در سرور');
} }
} catch (err) { } catch (err) {
// 3. Fallback offline
const payload = {
product_id: code,
product_name: productName,
warehouse,
shelfCode: shelfCode.toUpperCase(),
old_count: oldCount || 0,
new_count: Number(newCount),
user_id: user?.id,
mode: 'ITEM'
};
await saveCountOffline(payload); await saveCountOffline(payload);
alert('ارتباط با سرور قطع است. اطلاعات در گوشی شما ذخیره شد و بعداً ارسال می‌شود.'); alert('ارتباط با سرور قطع است. اطلاعات موقتاً در گوشی شما ذخیره شد.');
setHistory([{ shelf: shelfCode.toUpperCase(), count: newCount, offline: true }, ...history]); setHistory([{ shelf: shelfCode.toUpperCase(), count: newCount, offline: true }, ...history]);
setShelfCode(''); setShelfCode('');
setNewCount(''); setNewCount('');
@@ -115,72 +175,117 @@ function ItemCountingContent() {
const isBlind = settings?.blind_counting && !hasRole(user?.roles, ['ADMIN', 'SUPERVISOR']); const isBlind = settings?.blind_counting && !hasRole(user?.roles, ['ADMIN', 'SUPERVISOR']);
if (loading) { if (loading) {
return <div className="min-h-screen flex items-center justify-center bg-gray-50"><div className="animate-pulse font-bold text-gray-500">در حال دریافت اطلاعات...</div></div>; return (
<div className="w-full min-h-screen bg-gray-50 flex flex-col">
<Header title="انبارگردانی کالا" showBack={true} />
<div className="flex-1 flex justify-center items-center">
<div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
</div>
);
} }
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} />
{/* Top Banner */} {/* Top Banner */}
<div className="bg-gray-900 text-white px-6 py-4 shadow-md flex items-center gap-3"> <div className="bg-gray-900 text-white px-5 py-4 shadow-md flex items-center gap-4">
<div className="w-10 h-10 bg-white/10 rounded-xl flex items-center justify-center"> <div className="w-12 h-12 bg-white/10 rounded-2xl flex items-center justify-center shrink-0">
<Box size={20} /> <Box size={24} />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h2 className="text-sm font-bold leading-tight">{productName}</h2> <h2 className="text-sm font-bold leading-snug">{productName}</h2>
<p className="text-xs text-gray-400 mt-1 font-medium tracking-wider">کد: {code}</p> <p className="text-xs text-gray-400 mt-1 font-medium tracking-wider dir-ltr text-right">کد: {code}</p>
</div> </div>
{!isBlind && oldCount !== null && ( {!isBlind && oldCount !== null && (
<div className="bg-white/10 px-3 py-2 rounded-xl flex flex-col items-center"> <div className="bg-white/10 px-3 py-2 rounded-xl flex flex-col items-center shrink-0 border border-white/5">
<span className="text-sm font-black">{oldCount}</span> <span className="text-sm font-black text-indigo-300">{oldCount}</span>
<span className="text-[10px] text-gray-300">سیستم</span> <span className="text-[10px] text-gray-300 font-bold">موجودی سیستم</span>
</div> </div>
)} )}
</div> </div>
<div className="flex-1 p-4 md:p-6 max-w-md mx-auto w-full flex flex-col gap-6"> <div className="flex-1 p-4 md:p-6 max-w-md mx-auto w-full flex flex-col gap-6 mt-2">
{/* Input Form */} {/* Input Form */}
<div className="bg-white rounded-[24px] p-5 shadow-sm border border-gray-100 flex flex-col gap-4"> <div className="bg-white rounded-[24px] p-5 shadow-sm border border-gray-100 flex flex-col gap-4 relative overflow-hidden">
<h3 className="text-sm font-bold text-gray-800 mb-2">ثبت شمارش در قفسه</h3> <div className="absolute top-0 right-0 w-16 h-16 bg-indigo-50/50 rounded-bl-full -z-0"></div>
<div className="flex flex-col gap-3"> <h3 className="text-sm font-bold text-gray-800 relative z-10 flex items-center gap-2">
<div className="relative"> <CheckCircle2 className="text-indigo-600" size={18} />
<div className="absolute inset-y-0 left-4 flex items-center pointer-events-none"> ثبت شمارش در قفسه
<Layers size={16} className="text-gray-400" /> </h3>
</div>
{errorMsg && (
<div className="bg-red-50 border border-red-100 text-red-600 px-4 py-3 rounded-2xl text-xs font-bold flex items-center gap-2">
<AlertCircle size={14} />
{errorMsg}
</div>
)}
<div className="flex flex-col gap-3 relative z-10">
<div className="flex gap-2">
<input <input
type="text" type="text"
dir="ltr" dir="ltr"
value={shelfCode} value={shelfCode}
onChange={(e) => setShelfCode(e.target.value)} onChange={(e) => { setShelfCode(e.target.value); setErrorMsg(''); }}
placeholder="کد قفسه (مثال A-1)..." placeholder="مثال: C-1"
className="w-full pl-10 pr-4 py-3 bg-gray-50 border border-gray-200 rounded-[16px] text-center text-sm font-bold text-gray-800 uppercase focus:outline-none focus:border-gray-500 focus:ring-2 focus:ring-gray-100 transition-all" className="flex-1 py-3.5 bg-gray-50 border border-gray-200 rounded-[16px] text-center text-lg font-black text-gray-800 uppercase focus:outline-none focus:border-indigo-500 focus:bg-white transition-all placeholder:text-sm placeholder:font-normal placeholder:text-gray-400"
/> />
<motion.button
whileTap={{ scale: 0.95 }}
onClick={() => setCameraEnabled(true)}
className="w-14 bg-indigo-50 text-indigo-600 rounded-[16px] flex items-center justify-center shrink-0 hover:bg-indigo-100 transition-colors border border-indigo-100"
>
<ScanLine size={20} strokeWidth={2.5} />
</motion.button>
</div> </div>
<AnimatePresence>
{cameraEnabled && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="w-full aspect-video relative flex flex-col items-center justify-center bg-black overflow-hidden rounded-[16px]"
>
{camError ? (
<div className="text-red-400 text-xs p-4 text-center font-medium">{camError}</div>
) : (
<div className="w-full h-full [&>div]:!object-cover [&>div>video]:!object-cover">
<Scanner onScan={handleScan} onError={handleError} />
</div>
)}
<button
onClick={() => { setCameraEnabled(false); setCamError(''); }}
className="absolute top-2 right-2 bg-white/20 backdrop-blur-md p-1.5 rounded-full text-white pointer-events-auto"
>
<X size={16} strokeWidth={3} />
</button>
</motion.div>
)}
</AnimatePresence>
<input <input
type="number" type="number"
dir="ltr" dir="ltr"
value={newCount} value={newCount}
onChange={(e) => setNewCount(e.target.value)} onChange={(e) => { setNewCount(e.target.value); setErrorMsg(''); }}
placeholder="تعداد یافت شده در این قفسه" placeholder="تعداد یافت شده در این قفسه..."
className="w-full border-2 border-gray-200 bg-white rounded-[16px] p-4 text-center text-xl font-black text-gray-800 focus:outline-none focus:border-gray-900 focus:ring-4 focus:ring-gray-100 transition-all placeholder:text-sm placeholder:font-medium placeholder:text-gray-300" className="w-full border-2 border-gray-200 bg-white rounded-[16px] p-4 text-center text-2xl font-black text-gray-800 focus:outline-none focus:border-indigo-500 transition-all placeholder:text-sm placeholder:font-medium placeholder:text-gray-300"
/> />
<button <button
onClick={handleSubmit} onClick={validateShelfAndSubmit}
disabled={submitLoading || !newCount || !shelfCode} disabled={submitLoading || !newCount || !shelfCode}
className="w-full bg-gray-900 text-white p-4 rounded-[16px] shadow-md hover:bg-gray-800 transition-all disabled:opacity-50 text-sm font-bold flex items-center justify-center gap-2" className="w-full bg-indigo-600 text-white p-4 rounded-[16px] shadow-md shadow-indigo-600/20 hover:bg-indigo-700 transition-all disabled:opacity-50 text-sm font-black flex items-center justify-center gap-2 mt-1"
> >
{submitLoading ? ( {submitLoading ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
) : ( ) : (
<> <>ثبت قفسه و تعداد</>
<CheckCircle2 size={18} />
ثبت رکورد
</>
)} )}
</button> </button>
</div> </div>
@@ -188,17 +293,23 @@ function ItemCountingContent() {
{/* History of this item in different shelves */} {/* History of this item in different shelves */}
{history.length > 0 && ( {history.length > 0 && (
<div className="bg-white rounded-[24px] p-5 shadow-sm border border-gray-100"> <div className="flex flex-col gap-3 mt-2">
<h3 className="text-xs font-bold text-gray-400 mb-4 px-2">مکانهای یافت شده</h3> <h3 className="text-xs font-black text-gray-400 uppercase tracking-wider px-2">مکانهای ثبت شده شما</h3>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{history.map((item, idx) => ( {history.map((item, idx) => (
<div key={idx} className="flex justify-between items-center bg-gray-50 p-3 rounded-[16px] border border-gray-100"> <div key={idx} className="flex justify-between items-center bg-white p-4 rounded-[20px] shadow-sm border border-gray-100">
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<Layers size={16} className="text-gray-400" /> <div className="w-10 h-10 bg-gray-50 rounded-xl flex items-center justify-center">
<span className="text-sm font-bold text-gray-800">قفسه {item.shelf}</span> <Layers size={18} className="text-gray-400" />
</div>
<div className="flex flex-col">
<span className="text-sm font-black text-gray-800 uppercase tracking-widest">{item.shelf}</span>
{item.offline && <span className="text-[10px] text-yellow-600 font-bold mt-0.5">ثبت آفلاین</span>}
</div>
</div> </div>
<div className="bg-gray-200 text-gray-800 px-3 py-1 rounded-lg text-sm font-black shadow-sm"> <div className="flex flex-col items-end">
{item.count} عدد <span className="text-[10px] font-bold text-gray-400 mb-0.5">شمارش شده</span>
<span className="text-lg font-black text-indigo-600">{item.count}</span>
</div> </div>
</div> </div>
))} ))}
@@ -208,9 +319,9 @@ function ItemCountingContent() {
<button <button
onClick={handleFinish} onClick={handleFinish}
className="w-full py-4 bg-white border-2 border-gray-200 text-gray-700 text-sm font-extrabold rounded-[20px] transition-all hover:bg-gray-50 mt-4" className="w-full py-4 bg-white border border-gray-200 text-gray-600 text-sm font-extrabold rounded-[20px] transition-all hover:bg-gray-50 hover:text-gray-900 mt-2 shadow-sm"
> >
پایان شمارش این کالا پایان کار با این کالا
</button> </button>
</div> </div>
+213 -91
View File
@@ -1,10 +1,14 @@
'use client'; 'use client';
import { useState, useEffect, Suspense, useRef } from 'react'; import { useState, useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Header from '@/components/Header'; import Header from '@/components/Header';
import { Lock, Unlock, ScanLine, Search, PlusCircle, Check, Box } from 'lucide-react'; import { Lock, Unlock, ScanLine, Search, Check, Box, Layers, AlertCircle, X } from 'lucide-react';
import { hasRole } from '@/lib/auth'; import { hasRole } from '@/lib/auth';
import { saveCountOffline, syncOfflineCounts } from '@/lib/offlineSync'; import { saveCountOffline, syncOfflineCounts } from '@/lib/offlineSync';
import dynamic from 'next/dynamic';
import { motion, AnimatePresence } from 'framer-motion';
const Scanner = dynamic(() => import('@yudiel/react-qr-scanner').then(mod => mod.Scanner), { ssr: false });
function ShelfCountingContent() { function ShelfCountingContent() {
const router = useRouter(); const router = useRouter();
@@ -21,8 +25,13 @@ function ShelfCountingContent() {
const [productName, setProductName] = useState(''); const [productName, setProductName] = useState('');
const [oldCount, setOldCount] = useState(null); const [oldCount, setOldCount] = useState(null);
const [newCount, setNewCount] = useState(''); const [newCount, setNewCount] = useState('');
const [itemLoading, setItemLoading] = useState(false); const [itemLoading, setItemLoading] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false); const [submitLoading, setSubmitLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
const [cameraEnabled, setCameraEnabled] = useState(false);
const [camError, setCamError] = useState('');
// History of scanned items in this session // History of scanned items in this session
const [history, setHistory] = useState([]); const [history, setHistory] = useState([]);
@@ -32,7 +41,6 @@ function ShelfCountingContent() {
if (userData) setUser(JSON.parse(userData)); if (userData) setUser(JSON.parse(userData));
fetchSettings(); fetchSettings();
// Attempt to sync offline counts when page loads and we're online
window.addEventListener('online', syncOfflineCounts); window.addEventListener('online', syncOfflineCounts);
return () => window.removeEventListener('online', syncOfflineCounts); return () => window.removeEventListener('online', syncOfflineCounts);
}, []); }, []);
@@ -65,11 +73,19 @@ function ShelfCountingContent() {
} }
}; };
const fetchItemData = async (code) => { const fetchItemData = async (codeToFetch) => {
if (!code) return; const code = codeToFetch || productCode;
if (!code) {
setErrorMsg('کد کالا را وارد کنید');
return;
}
setErrorMsg('');
setItemLoading(true); setItemLoading(true);
setProductName(''); setProductName('');
setOldCount(null); setOldCount(null);
setNewCount('');
try { try {
const nameRes = await fetch('/api/hesabfa', { const nameRes = await fetch('/api/hesabfa', {
method: 'POST', method: 'POST',
@@ -77,7 +93,14 @@ function ShelfCountingContent() {
body: JSON.stringify({ code, type: 'name' }) body: JSON.stringify({ code, type: 'name' })
}); });
const nameData = await nameRes.json(); const nameData = await nameRes.json();
setProductName(nameData?.Result?.Name || 'نامشخص');
if (!nameData?.Result?.Name || nameData?.Result?.Name === 'نامشخص' || nameData.error) {
setErrorMsg('کالایی با این کد در حسابفا یافت نشد.');
setItemLoading(false);
return;
}
setProductName(nameData.Result.Name);
const qRes = await fetch('/api/hesabfa', { const qRes = await fetch('/api/hesabfa', {
method: 'POST', method: 'POST',
@@ -90,28 +113,53 @@ function ShelfCountingContent() {
setOldCount(wInfo?.Quantity ?? 0); setOldCount(wInfo?.Quantity ?? 0);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setProductName('خطا در دریافت اطلاعات'); setErrorMsg('خطا در ارتباط با حسابفا');
} finally { } finally {
setItemLoading(false); setItemLoading(false);
} }
}; };
const handleProductCodeChange = (e) => { const handleProductCodeChange = (e) => {
const code = e.target.value; setProductCode(e.target.value);
setProductCode(code); setErrorMsg('');
setProductName('');
}; };
const handleProductCodeKeyDown = (e) => { const handleProductCodeKeyDown = (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
fetchItemData(productCode); fetchItemData();
}
};
const handleScan = (detectedCodes) => {
if (detectedCodes && detectedCodes.length > 0) {
const scannedValue = detectedCodes[0].rawValue;
setProductCode(scannedValue);
setCameraEnabled(false);
setCamError('');
fetchItemData(scannedValue);
}
};
const handleError = (error) => {
const msg = error?.message || error?.name || '';
if (msg.includes('Requested device not found')) {
setCamError('دوربینی یافت نشد.');
} else {
setCamError('خطا در دسترسی به دوربین.');
} }
}; };
const handleSubmitItem = async () => { const handleSubmitItem = async () => {
if (newCount === '' || newCount === null) { if (!productName || errorMsg) {
alert('لطفاً تعداد را وارد کنید'); setErrorMsg('ابتدا از صحت کالا اطمینان حاصل کنید');
return; return;
} }
if (newCount === '' || newCount === null) {
setErrorMsg('لطفاً تعداد را وارد کنید');
return;
}
setSubmitLoading(true); setSubmitLoading(true);
const payload = { const payload = {
@@ -138,8 +186,9 @@ function ShelfCountingContent() {
setProductName(''); setProductName('');
setOldCount(null); setOldCount(null);
setNewCount(''); setNewCount('');
setErrorMsg('');
} else { } else {
alert('خطا در ثبت کالا'); setErrorMsg('خطا در ثبت شمارش کالا در سرور');
} }
} catch (err) { } catch (err) {
// Network error (offline or server down) // Network error (offline or server down)
@@ -150,6 +199,7 @@ function ShelfCountingContent() {
setProductName(''); setProductName('');
setOldCount(null); setOldCount(null);
setNewCount(''); setNewCount('');
setErrorMsg('');
} finally { } finally {
setSubmitLoading(false); setSubmitLoading(false);
} }
@@ -157,118 +207,190 @@ function ShelfCountingContent() {
const isBlind = settings?.blind_counting && !hasRole(user?.roles, ['ADMIN', 'SUPERVISOR']); const isBlind = settings?.blind_counting && !hasRole(user?.roles, ['ADMIN', 'SUPERVISOR']);
if (loading) {
return (
<div className="w-full min-h-screen bg-gray-50 flex flex-col">
<Header title="انبارگردانی قفسه" showBack={true} />
<div className="flex-1 flex justify-center items-center">
<div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
</div>
);
}
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} />
{/* Top Banner */} {/* Top Banner */}
<div className="bg-indigo-600 text-white px-6 py-4 flex items-center justify-between shadow-md"> <div className="bg-indigo-600 text-white px-5 py-4 flex items-center justify-between shadow-md">
<div> <div className="flex items-center gap-3">
<p className="text-xs text-indigo-200 font-medium mb-1">شما در حال شمارش هستید</p> <div className="w-10 h-10 bg-white/10 rounded-xl flex items-center justify-center">
<div className="flex items-center gap-2"> <Layers size={20} />
<Lock size={18} /> </div>
<h2 className="text-xl font-black">قفسه {shelfCode}</h2> <div className="flex flex-col">
<span className="text-[10px] text-indigo-200 font-bold mb-0.5 tracking-wider">در حال شمارش قفسه</span>
<span className="text-xl font-black tracking-widest uppercase">{shelfCode}</span>
</div> </div>
</div> </div>
<button <button
onClick={handleFinishShelf} onClick={handleFinishShelf}
className="bg-white/20 hover:bg-white/30 text-white text-xs font-bold py-2 px-4 rounded-xl transition-colors flex items-center gap-2 backdrop-blur-sm" className="bg-white/20 hover:bg-white/30 text-white text-xs font-black py-2.5 px-4 rounded-[14px] transition-colors flex items-center gap-2 backdrop-blur-sm shadow-sm"
> >
<Unlock size={14} /> <Unlock size={14} strokeWidth={3} />
خروج و پایان پایان قفسه
</button> </button>
</div> </div>
<div className="flex-1 p-4 md:p-6 max-w-md mx-auto w-full flex flex-col gap-6"> <div className="flex-1 p-4 md:p-6 max-w-md mx-auto w-full flex flex-col gap-6 mt-2">
{/* Scanner Input */} {/* Scanner Form */}
<div className="bg-white rounded-[24px] p-5 shadow-sm border border-gray-100 flex flex-col gap-4 relative z-10"> <div className="bg-white rounded-[24px] p-5 shadow-sm border border-gray-100 flex flex-col gap-4 relative overflow-hidden">
<div className="flex items-center gap-2 mb-2 text-indigo-600"> <div className="absolute top-0 left-0 w-16 h-16 bg-blue-50/50 rounded-br-full -z-0"></div>
<ScanLine size={18} />
<span className="text-sm font-bold text-gray-800">اسکن کالای جدید در این قفسه</span>
</div>
<div className="flex gap-2"> <h3 className="text-sm font-bold text-gray-800 relative z-10 flex items-center gap-2">
<input <ScanLine className="text-indigo-600" size={18} />
type="number" اسکن کالای جدید در این قفسه
dir="ltr" </h3>
value={productCode}
onChange={handleProductCodeChange}
onKeyDown={handleProductCodeKeyDown}
placeholder="بارکد کالا..."
className="flex-1 bg-gray-50 border border-gray-200 rounded-[16px] px-4 py-3 text-lg font-bold text-gray-800 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100 transition-all text-center"
/>
<button
onClick={() => fetchItemData(productCode)}
className="bg-indigo-50 text-indigo-600 p-3 rounded-[16px] flex items-center justify-center hover:bg-indigo-100 transition-colors"
>
<Search size={20} strokeWidth={2.5} />
</button>
</div>
{itemLoading && ( {errorMsg && (
<div className="text-center py-4 text-xs font-bold text-indigo-500 animate-pulse"> <div className="bg-red-50 border border-red-100 text-red-600 px-4 py-3 rounded-2xl text-xs font-bold flex items-center gap-2 relative z-10">
در حال استعلام از حسابفا... <AlertCircle size={14} className="shrink-0" />
{errorMsg}
</div> </div>
)} )}
{productName && !itemLoading && ( <div className="flex flex-col gap-3 relative z-10">
<div className="mt-2 pt-4 border-t border-gray-100 flex flex-col gap-4"> <div className="flex gap-2">
<div className="flex justify-between items-start"> <input
<div> type="number"
<h3 className="font-bold text-gray-800 text-sm">{productName}</h3> dir="ltr"
<p className="text-xs text-gray-400 mt-1">کد: {productCode}</p> value={productCode}
</div> onChange={handleProductCodeChange}
{!isBlind && oldCount !== null && ( onKeyDown={handleProductCodeKeyDown}
<div className="bg-green-50 text-green-700 px-3 py-1.5 rounded-lg flex flex-col items-center"> placeholder="بارکد یا کد کالا..."
<span className="text-sm font-black">{oldCount}</span> className="flex-1 bg-gray-50 border border-gray-200 rounded-[16px] px-4 py-3.5 text-lg font-bold text-gray-800 focus:outline-none focus:border-indigo-500 focus:bg-white transition-all text-center placeholder:text-sm placeholder:font-normal placeholder:text-gray-400"
<span className="text-[10px] font-bold">سیستم</span> />
</div> <motion.button
)} whileTap={{ scale: 0.95 }}
</div> onClick={() => fetchItemData()}
disabled={itemLoading || !productCode}
className="w-14 bg-indigo-50 text-indigo-600 rounded-[16px] flex items-center justify-center shrink-0 hover:bg-indigo-100 transition-colors border border-indigo-100 disabled:opacity-50"
>
{itemLoading ? <div className="w-5 h-5 border-2 border-indigo-600/30 border-t-indigo-600 rounded-full animate-spin"></div> : <Search size={20} strokeWidth={2.5} />}
</motion.button>
<motion.button
whileTap={{ scale: 0.95 }}
onClick={() => setCameraEnabled(true)}
className="w-14 bg-gray-900 text-white rounded-[16px] flex items-center justify-center shrink-0 shadow-md transition-colors"
>
<ScanLine size={20} strokeWidth={2.5} />
</motion.button>
</div>
<div className="flex items-center gap-3"> <AnimatePresence>
<input {cameraEnabled && (
type="number" <motion.div
dir="ltr" initial={{ height: 0, opacity: 0 }}
value={newCount} animate={{ height: 'auto', opacity: 1 }}
onChange={(e) => setNewCount(e.target.value)} exit={{ height: 0, opacity: 0 }}
placeholder="تعداد شمارش شده..." className="w-full aspect-video relative flex flex-col items-center justify-center bg-black overflow-hidden rounded-[16px]"
className="flex-1 border-2 border-gray-200 bg-white rounded-[16px] p-3 text-center text-xl font-black text-gray-800 focus:outline-none focus:border-indigo-500 focus:ring-4 focus:ring-indigo-50 transition-all placeholder:text-sm placeholder:font-medium placeholder:text-gray-300"
/>
<button
onClick={handleSubmitItem}
disabled={submitLoading || !newCount}
className="bg-gray-900 text-white p-4 rounded-[16px] shadow-md hover:bg-gray-800 transition-all disabled:opacity-50"
> >
{submitLoading ? <div className="w-6 h-6 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> : <Check size={24} />} {camError ? (
</button> <div className="text-red-400 text-xs p-4 text-center font-medium">{camError}</div>
</div> ) : (
</div> <div className="w-full h-full [&>div]:!object-cover [&>div>video]:!object-cover">
)} <Scanner onScan={handleScan} onError={handleError} />
</div>
)}
<button
onClick={() => { setCameraEnabled(false); setCamError(''); }}
className="absolute top-2 right-2 bg-white/20 backdrop-blur-md p-1.5 rounded-full text-white pointer-events-auto"
>
<X size={16} strokeWidth={3} />
</button>
</motion.div>
)}
</AnimatePresence>
{/* Product Details & Count input (only shown if valid product found) */}
<AnimatePresence>
{productName && !itemLoading && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-2 pt-4 border-t border-gray-100 flex flex-col gap-4"
>
<div className="flex justify-between items-start bg-indigo-50/50 p-3 rounded-[16px] border border-indigo-50">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-white rounded-xl flex items-center justify-center text-indigo-500 shadow-sm shrink-0">
<Box size={16} />
</div>
<div className="flex flex-col">
<h3 className="font-bold text-gray-800 text-sm leading-tight">{productName}</h3>
<p className="text-[10px] text-gray-500 mt-1 font-medium">کد حسابفا: {productCode}</p>
</div>
</div>
{!isBlind && oldCount !== null && (
<div className="bg-white border border-gray-100 text-indigo-600 px-3 py-1.5 rounded-xl flex flex-col items-center shadow-sm shrink-0 ml-1">
<span className="text-sm font-black">{oldCount}</span>
<span className="text-[9px] font-bold text-gray-400">موجودی سیستم</span>
</div>
)}
</div>
<div className="flex flex-col gap-3">
<input
type="number"
dir="ltr"
value={newCount}
onChange={(e) => { setNewCount(e.target.value); setErrorMsg(''); }}
placeholder="تعداد شمارش شده را وارد کنید..."
className="w-full border-2 border-gray-200 bg-white rounded-[16px] p-4 text-center text-2xl font-black text-gray-800 focus:outline-none focus:border-indigo-500 transition-all placeholder:text-sm placeholder:font-medium placeholder:text-gray-300"
/>
<button
onClick={handleSubmitItem}
disabled={submitLoading || !newCount}
className="w-full bg-indigo-600 text-white py-4 rounded-[16px] shadow-md shadow-indigo-600/20 hover:bg-indigo-700 transition-all disabled:opacity-50 text-sm font-black flex items-center justify-center gap-2"
>
{submitLoading ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
) : (
<>ثبت شمارش</>
)}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div> </div>
{/* History of this session */} {/* History of this session */}
{history.length > 0 && ( {history.length > 0 && (
<div className="bg-white rounded-[24px] p-5 shadow-sm border border-gray-100"> <div className="flex flex-col gap-3 mt-2">
<h3 className="text-xs font-bold text-gray-400 mb-4 px-2">کالاهای ثبت شده در این قفسه</h3> <h3 className="text-xs font-black text-gray-400 uppercase tracking-wider px-2">کالاهای ثبت شده تا الان</h3>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{history.map((item, idx) => ( {history.map((item, idx) => (
<div key={idx} className="flex justify-between items-center bg-gray-50 p-3 rounded-[16px] border border-gray-100 relative overflow-hidden"> <div key={idx} className="flex justify-between items-center bg-white p-4 rounded-[20px] shadow-sm border border-gray-100 relative overflow-hidden">
{item.offline && ( {item.offline && (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-yellow-400"></div> <div className="absolute left-0 top-0 bottom-0 w-1 bg-yellow-400"></div>
)} )}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center text-gray-400 border border-gray-200"> <div className="w-10 h-10 bg-gray-50 rounded-xl flex items-center justify-center border border-gray-100">
<Box size={14} /> <Box size={18} className="text-gray-400" />
</div> </div>
<div> <div className="flex flex-col">
<p className="text-xs font-bold text-gray-800 line-clamp-1">{item.name}</p> <p className="text-xs font-bold text-gray-800 line-clamp-1">{item.name}</p>
<p className="text-[10px] font-medium text-gray-400">{item.code} {item.offline && <span className="text-yellow-600 ml-1">(آفلاین)</span>}</p> <p className="text-[10px] font-medium text-gray-400 mt-0.5">کد: {item.code}</p>
</div> </div>
</div> </div>
<div className="bg-indigo-100 text-indigo-700 px-3 py-1 rounded-lg text-sm font-black shadow-sm"> <div className="flex flex-col items-end shrink-0 pl-1">
{item.count} <span className="text-[10px] font-bold text-gray-400 mb-0.5">شمارش شما</span>
<span className="text-lg font-black text-indigo-600">
{item.count}
</span>
</div> </div>
</div> </div>
))} ))}
+77 -13
View File
@@ -1,10 +1,36 @@
'use client'; 'use client';
import { useState, useEffect } from 'react';
import Header from '@/components/Header'; import Header from '@/components/Header';
import Link from 'next/link'; import Link from 'next/link';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { History, ScanLine, ListChecks } from 'lucide-react'; import { History, ScanLine, ListChecks, AlertTriangle, Layers, MapPin } from 'lucide-react';
export default function Dashboard() { export default function Dashboard() {
const [uncountedShelves, setUncountedShelves] = useState([]);
const [settings, setSettings] = useState(null);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
const setRes = await fetch('/api/settings');
let currentSettings = { uncounted_shelf_days: 10 };
if (setRes.ok) {
currentSettings = await setRes.json();
setSettings(currentSettings);
}
const uncRes = await fetch(`/api/reports/uncounted?days=${currentSettings.uncounted_shelf_days || 10}`);
if (uncRes.ok) {
const uncData = await uncRes.json();
setUncountedShelves(uncData.uncounted || []);
}
} catch (e) {
console.error(e);
}
};
const container = { const container = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
@@ -20,48 +46,86 @@ export default function Dashboard() {
}; };
return ( return (
<div className="w-full min-h-screen bg-gray-50 flex flex-col"> <div className="w-full min-h-screen bg-gray-50 flex flex-col pb-24 overflow-x-hidden">
<Header title="داشبورد" /> <Header title="داشبورد" />
<motion.div <motion.div
variants={container} variants={container}
initial="hidden" initial="hidden"
animate="show" animate="show"
className="p-5 flex flex-col gap-4" className="p-4 md:p-6 flex flex-col gap-6 max-w-lg mx-auto w-full mt-2"
> >
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<motion.div variants={item}> <motion.div variants={item}>
<Link href="/history" className="bg-white/80 backdrop-blur-sm border border-gray-100 rounded-3xl shadow-[0_4px_20px_rgb(0,0,0,0.03)] p-6 flex flex-col items-center justify-center aspect-square gap-3 hover:bg-white hover:scale-[1.02] active:scale-95 transition-all"> <Link href="/history" className="bg-white/80 backdrop-blur-sm border border-gray-100 rounded-3xl shadow-sm p-6 flex flex-col items-center justify-center aspect-square gap-3 hover:bg-white hover:scale-[1.02] active:scale-95 transition-all">
<div className="w-12 h-12 bg-blue-50 text-blue-500 rounded-2xl flex items-center justify-center mb-1"> <div className="w-12 h-12 bg-blue-50 text-blue-500 rounded-2xl flex items-center justify-center mb-1">
<History strokeWidth={1.5} size={24} /> <History strokeWidth={2} size={24} />
</div> </div>
<span className="font-extrabold text-xs text-gray-700">تاریخچه شمارش</span> <span className="font-black text-xs text-gray-700">تاریخچه شمارش</span>
</Link> </Link>
</motion.div> </motion.div>
<motion.div variants={item}> <motion.div variants={item}>
<Link href="/scan" className="bg-white/80 backdrop-blur-sm border border-gray-100 rounded-3xl shadow-[0_4px_20px_rgb(0,0,0,0.03)] p-6 flex flex-col items-center justify-center aspect-square gap-3 hover:bg-white hover:scale-[1.02] active:scale-95 transition-all"> <Link href="/scan" className="bg-white/80 backdrop-blur-sm border border-gray-100 rounded-3xl shadow-sm p-6 flex flex-col items-center justify-center aspect-square gap-3 hover:bg-white hover:scale-[1.02] active:scale-95 transition-all relative overflow-hidden">
<div className="w-12 h-12 bg-purple-50 text-purple-600 rounded-2xl flex items-center justify-center mb-1"> <div className="absolute top-0 right-0 w-16 h-16 bg-indigo-50/50 rounded-bl-full -z-0"></div>
<ScanLine strokeWidth={1.5} size={24} /> <div className="w-12 h-12 bg-indigo-50 text-indigo-600 rounded-2xl flex items-center justify-center mb-1 relative z-10">
<ScanLine strokeWidth={2.5} size={24} />
</div> </div>
<span className="font-extrabold text-xs text-gray-700">اسکن کالا</span> <span className="font-black text-xs text-gray-800 relative z-10">شروع انبارگردانی</span>
</Link> </Link>
</motion.div> </motion.div>
</div> </div>
<motion.div variants={item}> <motion.div variants={item}>
<Link href="/my-counts" className="bg-white/80 backdrop-blur-sm border border-gray-100 rounded-3xl shadow-[0_4px_20px_rgb(0,0,0,0.03)] p-5 flex items-center justify-between hover:bg-white hover:scale-[1.01] active:scale-[0.99] transition-all"> <Link href="/my-counts" className="bg-white/80 backdrop-blur-sm border border-gray-100 rounded-3xl shadow-sm p-5 flex items-center justify-between hover:bg-white hover:scale-[1.02] active:scale-95 transition-all">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-10 h-10 bg-green-50 text-green-600 rounded-xl flex items-center justify-center"> <div className="w-10 h-10 bg-green-50 text-green-600 rounded-xl flex items-center justify-center">
<ListChecks strokeWidth={1.5} size={20} /> <ListChecks strokeWidth={2} size={20} />
</div> </div>
<span className="font-extrabold text-sm text-gray-700">شمارشهای من</span> <span className="font-black text-sm text-gray-800">شمارشهای من</span>
</div> </div>
<div className="w-8 h-8 bg-gray-50 rounded-full flex items-center justify-center"> <div className="w-8 h-8 bg-gray-50 rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-gray-400 rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" /></svg> <svg className="w-4 h-4 text-gray-400 rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" /></svg>
</div> </div>
</Link> </Link>
</motion.div> </motion.div>
{/* Suggested Shelves to count */}
{uncountedShelves.length > 0 && (
<motion.div variants={item} className="flex flex-col gap-3 mt-4">
<div className="flex items-center justify-between px-2">
<h3 className="text-sm font-black text-gray-800 flex items-center gap-2">
<AlertTriangle size={18} className="text-orange-500" />
قفسههای پیشنهادی
</h3>
<span className="text-[10px] text-gray-400 font-bold bg-white px-2 py-1 rounded-md border border-gray-100">
بدون بررسی بالای {settings?.uncounted_shelf_days || 10} روز
</span>
</div>
<div className="grid grid-cols-2 gap-3">
{uncountedShelves.slice(0, 4).map((shelf, idx) => (
<Link
key={idx}
href={`/counting/shelf?code=${shelf.code}&warehouse=${shelf.warehouse}`}
className="bg-orange-50/50 border border-orange-100 rounded-[20px] p-4 flex flex-col gap-2 hover:bg-orange-50 transition-colors"
>
<div className="flex items-center justify-between">
<div className="w-8 h-8 bg-white rounded-[10px] flex items-center justify-center text-orange-400 shadow-sm">
<Layers size={14} />
</div>
<span className="text-xs font-black text-orange-600 uppercase tracking-widest">{shelf.code}</span>
</div>
<div className="flex items-center gap-1 mt-1 text-[10px] text-gray-500 font-bold">
<MapPin size={10} />
طبقه {shelf.floor}، انبار {shelf.warehouse}
</div>
</Link>
))}
</div>
</motion.div>
)}
</motion.div> </motion.div>
</div> </div>
); );
+176 -32
View File
@@ -1,52 +1,196 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Header from '@/components/Header'; import Header from '@/components/Header';
import { hasRole } from '@/lib/auth';
import { Edit2, Check, X, Box, Layers, User, Save } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
export default function HistoryPage() { export default function HistoryPage() {
const [counts, setCounts] = useState([]); const [counts, setCounts] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [user, setUser] = useState(null);
const [settings, setSettings] = useState(null);
const [editingId, setEditingId] = useState(null);
const [editValue, setEditValue] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
const fetchHistory = async () => { const userData = localStorage.getItem('user');
try { if (userData) setUser(JSON.parse(userData));
const res = await fetch(`/api/counting`);
if (res.ok) setCounts(await res.json()); fetchData();
} catch (e) {
console.error(e);
}
setLoading(false);
};
fetchHistory();
}, []); }, []);
const fetchData = async () => {
try {
const [histRes, setRes] = await Promise.all([
fetch('/api/counting'),
fetch('/api/settings')
]);
if (histRes.ok) setCounts(await histRes.json());
if (setRes.ok) setSettings(await setRes.json());
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
const handleEditSubmit = async (id) => {
if (editValue === '') return;
setSaving(true);
try {
const res = await fetch(`/api/counting/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_count: editValue, userId: user?.id })
});
if (res.ok) {
setCounts(counts.map(c => c.id === id ? { ...c, new_count: Number(editValue) } : c));
setEditingId(null);
} else {
alert('خطا در ثبت اصلاحیه');
}
} catch (e) {
alert('خطای شبکه');
} finally {
setSaving(false);
}
};
const canCorrect = settings?.correction_roles ? hasRole(user?.roles, settings.correction_roles) : false;
return ( return (
<div className="w-full min-h-screen bg-gray-50 flex flex-col pb-20"> <div className="w-full min-h-screen bg-gray-50 flex flex-col pb-24 overflow-x-hidden">
<Header title="تاریخچه کل شمارش‌ها" showBack={true} /> <Header title="تاریخچه کل شمارش‌ها" showBack={true} />
<div className="p-4 flex flex-col gap-4"> <div className="flex-1 p-4 md:p-6 max-w-2xl mx-auto w-full flex flex-col gap-4 mt-2">
{loading ? ( {loading ? (
<div className="text-center p-10">در حال دریافت...</div> <div className="flex flex-col justify-center items-center py-20 gap-3">
<div className="w-10 h-10 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
<p className="text-gray-500 font-bold text-sm">در حال دریافت تاریخچه...</p>
</div>
) : counts.length === 0 ? ( ) : counts.length === 0 ? (
<div className="text-center text-gray-500 p-10">تاریخچه خالی است.</div> <div className="bg-white rounded-[24px] border border-dashed border-gray-300 p-10 text-center flex flex-col items-center gap-3">
) : ( <div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center">
counts.map(count => ( <History size={32} className="text-gray-300" />
<div key={count.id} className="bg-white p-4 rounded shadow border border-gray-200">
<div className="flex justify-between items-start">
<div className="flex flex-col">
<span className="font-bold text-sm">{count.product_name}</span>
<span className="text-xs text-gray-500 mt-1">شمارنده: {count.user?.name} | انبار: {count.warehouse}</span>
<span className="text-xs text-gray-500 mt-1">قفسه: {count.shelf || 'ثبت نشده'}</span>
</div>
<div className="flex flex-col items-center bg-green-50 p-2 rounded border border-green-200">
<span className="font-bold text-green-700">{count.new_count}</span>
<span className="text-[10px] text-green-600">موجودی ثبت شده</span>
</div>
</div>
<div className="mt-2 text-[10px] text-gray-400 text-left">
{new Date(count.createdAt).toLocaleString('fa-IR')}
</div>
</div> </div>
)) <p className="font-bold text-gray-500 text-sm">هیچ رکورد انبارگردانی یافت نشد.</p>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between px-2 mb-2">
<h3 className="font-black text-gray-800 text-sm">آخرین رکوردهای ثبت شده</h3>
<span className="bg-indigo-50 text-indigo-600 px-3 py-1 rounded-full text-[10px] font-black">
{counts.length} رکورد
</span>
</div>
<AnimatePresence>
{counts.map(count => {
const isEditing = editingId === count.id;
return (
<motion.div
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
key={count.id}
className="bg-white p-4 rounded-[20px] shadow-sm border border-gray-100 flex flex-col gap-3"
>
<div className="flex justify-between items-start border-b border-gray-50 pb-3">
<div className="flex gap-3">
<div className="w-10 h-10 bg-indigo-50 text-indigo-500 rounded-[12px] flex items-center justify-center shrink-0">
<Box size={18} />
</div>
<div className="flex flex-col">
<span className="font-bold text-gray-800 text-sm leading-tight line-clamp-1">{count.product_name}</span>
<span className="text-[10px] text-gray-400 mt-1">کد: {count.product_id}</span>
</div>
</div>
{!isEditing && (
<div className="flex flex-col items-center bg-gray-50 px-3 py-1.5 rounded-xl border border-gray-100 shrink-0 ml-1">
<span className="font-black text-lg text-gray-800">{count.new_count}</span>
<span className="text-[9px] font-bold text-gray-400">شمارش شده</span>
</div>
)}
</div>
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-gray-500">
<Layers size={12} className="text-gray-400" />
قفسه: <span className="text-gray-800 uppercase tracking-widest">{count.shelfCode || count.shelf || 'ثبت نشده'}</span>
</div>
<div className="flex items-center gap-1.5 text-[10px] font-bold text-gray-500">
<User size={12} className="text-gray-400" />
شمارنده: <span className="text-gray-800">{count.user?.name || 'نامشخص'}</span>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<span className="text-[9px] text-gray-400 dir-ltr text-right font-medium">
{new Date(count.createdAt).toLocaleString('fa-IR')}
</span>
{canCorrect && !isEditing && (
<button
onClick={() => { setEditingId(count.id); setEditValue(count.new_count); }}
className="flex items-center gap-1 text-[10px] font-bold text-indigo-500 bg-indigo-50 hover:bg-indigo-100 px-2.5 py-1 rounded-lg transition-colors"
>
<Edit2 size={10} />
اصلاحیه
</button>
)}
</div>
</div>
{/* Edit Mode */}
<AnimatePresence>
{isEditing && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="pt-3 border-t border-gray-100 flex items-center gap-2 overflow-hidden"
>
<input
type="number"
dir="ltr"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
className="w-20 bg-gray-50 border border-gray-200 rounded-[12px] px-3 py-2 text-center text-sm font-black text-gray-800 focus:outline-none focus:border-indigo-500 transition-colors"
/>
<button
onClick={() => handleEditSubmit(count.id)}
disabled={saving}
className="bg-gray-900 text-white px-4 py-2 rounded-[12px] text-xs font-bold transition-all disabled:opacity-50 flex items-center gap-1.5"
>
{saving ? <div className="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> : <Save size={14} />}
ثبت
</button>
<button
onClick={() => setEditingId(null)}
disabled={saving}
className="bg-red-50 text-red-500 p-2 rounded-[12px] transition-all hover:bg-red-100 disabled:opacity-50"
>
<X size={16} />
</button>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
})}
</AnimatePresence>
</div>
)} )}
</div> </div>
</div> </div>
+1 -1
View File
@@ -150,7 +150,7 @@ export default function ScanPage() {
<ScanLine size={32} strokeWidth={2} /> <ScanLine size={32} strokeWidth={2} />
</div> </div>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<span className="text-sm font-extrabold text-gray-800 mb-1">פעالسازی دوربین</span> <span className="text-sm font-extrabold text-gray-800 mb-1">فعالسازی دوربین</span>
<span className="text-xs font-medium text-gray-400"> <span className="text-xs font-medium text-gray-400">
برای اسکن {mode === 'SHELF' ? 'بارکد قفسه' : 'بارکد کالا'} کلیک کنید برای اسکن {mode === 'SHELF' ? 'بارکد قفسه' : 'بارکد کالا'} کلیک کنید
</span> </span>