feat: complete Phase 2 counting features, UI redesign, and discrepancy dashboard
This commit is contained in:
@@ -1,13 +1,17 @@
|
|||||||
'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')
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
@@ -23,6 +27,10 @@ function ItemCountingContent() {
|
|||||||
const [newCount, setNewCount] = useState('');
|
const [newCount, setNewCount] = useState('');
|
||||||
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');
|
||||||
@@ -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>
|
||||||
|
|||||||
+214
-92
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 relative z-10">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
dir="ltr"
|
||||||
|
value={productCode}
|
||||||
|
onChange={handleProductCodeChange}
|
||||||
|
onKeyDown={handleProductCodeKeyDown}
|
||||||
|
placeholder="بارکد یا کد کالا..."
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<motion.button
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
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>
|
||||||
|
|
||||||
{productName && !itemLoading && (
|
<AnimatePresence>
|
||||||
<div className="mt-2 pt-4 border-t border-gray-100 flex flex-col gap-4">
|
{cameraEnabled && (
|
||||||
<div className="flex justify-between items-start">
|
<motion.div
|
||||||
<div>
|
initial={{ height: 0, opacity: 0 }}
|
||||||
<h3 className="font-bold text-gray-800 text-sm">{productName}</h3>
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
<p className="text-xs text-gray-400 mt-1">کد: {productCode}</p>
|
exit={{ height: 0, opacity: 0 }}
|
||||||
</div>
|
className="w-full aspect-video relative flex flex-col items-center justify-center bg-black overflow-hidden rounded-[16px]"
|
||||||
{!isBlind && oldCount !== null && (
|
|
||||||
<div className="bg-green-50 text-green-700 px-3 py-1.5 rounded-lg flex flex-col items-center">
|
|
||||||
<span className="text-sm font-black">{oldCount}</span>
|
|
||||||
<span className="text-[10px] font-bold">سیستم</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
dir="ltr"
|
|
||||||
value={newCount}
|
|
||||||
onChange={(e) => setNewCount(e.target.value)}
|
|
||||||
placeholder="تعداد شمارش شده..."
|
|
||||||
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
@@ -1,11 +1,37 @@
|
|||||||
'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 },
|
||||||
show: {
|
show: {
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user