feat: redesign settings, add warehouse/user/product management, update location schema

This commit is contained in:
2026-06-12 11:44:05 +03:30
parent 8104224a5a
commit 4675d7ce27
11 changed files with 740 additions and 138 deletions
+1
View File
@@ -53,6 +53,7 @@ model Location {
region Int region Int
sector String sector String
row Int row Int
warehouse Int?
countings Counting[] countings Counting[]
isLocked Boolean @default(false) isLocked Boolean @default(false)
lockedById Int? lockedById Int?
+35 -2
View File
@@ -8,6 +8,8 @@ const Scanner = dynamic(() => import('@yudiel/react-qr-scanner').then(mod => mod
export default function AdminLocations() { export default function AdminLocations() {
const [locations, setLocations] = useState([]); const [locations, setLocations] = useState([]);
const [warehouses, setWarehouses] = useState([]);
const [selectedWarehouse, setSelectedWarehouse] = useState('');
const [newCode, setNewCode] = useState(''); const [newCode, setNewCode] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [cameraEnabled, setCameraEnabled] = useState(false); const [cameraEnabled, setCameraEnabled] = useState(false);
@@ -26,8 +28,24 @@ export default function AdminLocations() {
useEffect(() => { useEffect(() => {
fetchLocations(); fetchLocations();
fetchSettings();
}, []); }, []);
const fetchSettings = async () => {
try {
const res = await fetch('/api/settings');
if (res.ok) {
const data = await res.json();
setWarehouses(data.warehouses || []);
if (data.warehouses?.length > 0) {
setSelectedWarehouse(data.warehouses[0].id);
}
}
} catch (e) {
console.error(e);
}
};
const fetchLocations = async () => { const fetchLocations = async () => {
try { try {
const res = await fetch('/api/locations'); const res = await fetch('/api/locations');
@@ -42,6 +60,10 @@ export default function AdminLocations() {
const handleAddOrEdit = async (codeValue) => { const handleAddOrEdit = async (codeValue) => {
const targetCode = editingId ? editCode : (codeValue || newCode); const targetCode = editingId ? editCode : (codeValue || newCode);
if (!targetCode) return; if (!targetCode) return;
if (!selectedWarehouse) {
showToast('لطفاً ابتدا انبار را انتخاب کنید', true);
return;
}
setLoading(true); setLoading(true);
try { try {
@@ -51,7 +73,7 @@ export default function AdminLocations() {
const res = await fetch(url, { const res = await fetch(url, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: targetCode }) body: JSON.stringify({ code: targetCode, warehouse: selectedWarehouse })
}); });
const data = await res.json(); const data = await res.json();
@@ -135,6 +157,17 @@ export default function AdminLocations() {
)} )}
</div> </div>
<select
value={selectedWarehouse}
onChange={(e) => setSelectedWarehouse(e.target.value)}
className="w-full bg-gray-50 border border-gray-200 rounded-[16px] px-4 py-3 text-sm font-bold text-gray-800 focus:outline-none focus:border-indigo-500 transition-colors"
>
<option value="" disabled>انبار را انتخاب کنید...</option>
{warehouses.map(wh => (
<option key={wh.id} value={wh.id}>{wh.name} (کد: {wh.id})</option>
))}
</select>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="text" type="text"
@@ -239,7 +272,7 @@ export default function AdminLocations() {
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-black text-lg text-gray-800 tracking-wide uppercase">{loc.code}</span> <span className="font-black text-lg text-gray-800 tracking-wide uppercase">{loc.code}</span>
<span className="text-[10px] text-gray-400 font-bold mt-0.5"> <span className="text-[10px] text-gray-400 font-bold mt-0.5">
طبقه {loc.floor} منطقه {loc.region} قطاع {loc.sector} {loc.warehouse ? `انبار: ${loc.warehouse}` : ''}طبقه {loc.floor} منطقه {loc.region} قطاع {loc.sector}
</span> </span>
</div> </div>
</div> </div>
+132
View File
@@ -0,0 +1,132 @@
'use client';
import { useState, useEffect } from 'react';
import Header from '@/components/Header';
import { PackageOpen, Search, Filter, Box } from 'lucide-react';
import { motion } from 'framer-motion';
export default function ProductsPage() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [filterStock, setFilterStock] = useState('all'); // 'all', 'in-stock', 'out-of-stock'
useEffect(() => {
fetchProducts();
}, []);
const fetchProducts = async () => {
try {
const res = await fetch('/api/hesabfa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'all' })
});
if (res.ok) {
const data = await res.json();
if (data.Success && data.Result?.List) {
setProducts(data.Result.List);
}
}
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
const filteredProducts = products.filter(p => {
const matchesSearch = p.Name.includes(searchTerm) || p.Code.includes(searchTerm);
if (!matchesSearch) return false;
if (filterStock === 'in-stock') return p.Stock > 0;
if (filterStock === 'out-of-stock') return p.Stock <= 0;
return true;
});
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 (
<div className="w-full min-h-screen bg-gray-50 flex flex-col pb-24 relative overflow-x-hidden">
<Header title="محصولات حسابفا" showBack={true} />
<div className="flex-1 p-5 flex flex-col gap-6 max-w-3xl mx-auto w-full mt-2">
<div className="flex flex-col">
<h2 className="text-xl font-black text-gray-800 tracking-tight">موجودی کالاها</h2>
<p className="text-xs text-gray-400 font-medium mt-1">لیست زنده کالاهای ثبت شده در نرمافزار حسابفا</p>
</div>
<div className="flex flex-col md:flex-row gap-3">
<div className="relative flex-1">
<input
type="text"
placeholder="جستجو با نام یا کد کالا..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full bg-white border border-gray-200 rounded-[16px] pr-12 pl-4 py-3.5 text-sm focus:outline-none focus:border-indigo-500 transition-colors shadow-sm"
/>
<Search className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
</div>
<div className="flex gap-2 shrink-0">
{['all', 'in-stock', 'out-of-stock'].map(filter => (
<button
key={filter}
onClick={() => setFilterStock(filter)}
className={`px-4 py-3 rounded-[16px] text-xs font-bold transition-all border shadow-sm ${
filterStock === filter
? 'bg-gray-900 text-white border-gray-900'
: 'bg-white text-gray-500 border-gray-200 hover:bg-gray-50'
}`}
>
{filter === 'all' ? 'همه' : filter === 'in-stock' ? 'موجود' : 'ناموجود'}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-3">
{filteredProducts.map(p => (
<motion.div
key={p.Code}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white p-4 rounded-[20px] border border-gray-100 shadow-sm flex items-center justify-between gap-4"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-gray-50 rounded-[14px] flex items-center justify-center shrink-0">
<PackageOpen className="text-gray-400" size={20} strokeWidth={2} />
</div>
<div className="flex flex-col">
<span className="font-bold text-gray-800 text-sm line-clamp-1">{p.Name}</span>
<span className="text-[11px] text-gray-400 font-bold mt-0.5" dir="ltr">{p.Code}</span>
</div>
</div>
<div className={`shrink-0 px-3 py-1.5 rounded-[12px] flex items-center gap-1.5 ${p.Stock > 0 ? 'bg-green-50 text-green-600 border border-green-100' : 'bg-gray-50 text-gray-500 border border-gray-100'}`}>
<Box size={14} strokeWidth={2.5} />
<span className="text-xs font-black">{p.Stock} {p.Unit || 'عدد'}</span>
</div>
</motion.div>
))}
{filteredProducts.length === 0 && (
<div className="py-12 flex flex-col items-center justify-center text-center">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-3">
<Filter className="text-gray-400" size={24} />
</div>
<span className="text-sm font-bold text-gray-500">کالایی یافت نشد</span>
</div>
)}
</div>
</div>
</div>
);
}
+102 -130
View File
@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Save, EyeOff, ShieldCheck, Check } from 'lucide-react'; import Header from '@/components/Header';
import { motion } from 'framer-motion'; import { Save, EyeOff, ShieldCheck, Check, AlertCircle, XCircle } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
export default function SettingsPage() { export default function SettingsPage() {
const [settings, setSettings] = useState({ const [settings, setSettings] = useState({
@@ -10,7 +11,7 @@ export default function SettingsPage() {
}); });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false); const [toast, setToast] = useState({ show: false, message: '', isError: false });
const availableRoles = [ const availableRoles = [
{ id: 'ADMIN', label: 'مدیر کل' }, { id: 'ADMIN', label: 'مدیر کل' },
@@ -19,6 +20,11 @@ export default function SettingsPage() {
{ id: 'COUNTER', label: 'انبارگردان' } { id: 'COUNTER', label: 'انبارگردان' }
]; ];
const showToast = (message, isError = false) => {
setToast({ show: true, message, isError });
setTimeout(() => setToast({ show: false, message: '', isError: false }), 3000);
};
useEffect(() => { useEffect(() => {
fetchSettings(); fetchSettings();
}, []); }, []);
@@ -39,7 +45,6 @@ export default function SettingsPage() {
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
setSaved(false);
try { try {
const res = await fetch('/api/settings', { const res = await fetch('/api/settings', {
method: 'POST', method: 'POST',
@@ -47,11 +52,13 @@ export default function SettingsPage() {
body: JSON.stringify(settings) body: JSON.stringify(settings)
}); });
if (res.ok) { if (res.ok) {
setSaved(true); showToast('تنظیمات با موفقیت ذخیره شد');
setTimeout(() => setSaved(false), 3000); } else {
showToast('خطا در ذخیره تنظیمات', true);
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
showToast('خطای شبکه', true);
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -68,158 +75,123 @@ export default function SettingsPage() {
}); });
}; };
if (loading) return <div className="p-8 flex justify-center"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></div></div>; 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="max-w-3xl mx-auto p-4 md:p-8 space-y-8"> <div className="w-full min-h-screen bg-gray-50 flex flex-col pb-24 relative overflow-x-hidden">
<div> <Header title="تنظیمات سیستم" showBack={true} />
<h1 className="text-2xl font-black text-gray-800 tracking-tight">تنظیمات سیستم</h1>
<p className="text-sm text-gray-500 font-medium mt-1">مدیریت قوانین انبارگردانی و دسترسیها</p>
</div>
<div className="bg-white rounded-[24px] p-6 shadow-sm border border-gray-100 space-y-8"> <div className="flex-1 p-5 flex flex-col gap-6 max-w-lg mx-auto w-full mt-2">
<div className="flex flex-col">
<h2 className="text-xl font-black text-gray-800 tracking-tight">تنظیمات اصلی</h2>
<p className="text-xs text-gray-400 font-medium mt-1">مدیریت قوانین انبارگردانی و دسترسیها</p>
</div>
{/* Blind Counting Setting */} <div className="w-full bg-white rounded-[24px] p-6 shadow-sm border border-gray-100 flex flex-col gap-8">
<div className="flex items-start justify-between gap-4 border-b border-gray-50 pb-8">
{/* Blind Counting Setting */}
<div className="flex items-start justify-between gap-4 border-b border-gray-50 pb-8">
<div className="flex-1 pr-2">
<div className="flex items-center gap-2 mb-2">
<div className="w-8 h-8 rounded-[12px] bg-purple-50 text-purple-600 flex items-center justify-center shrink-0">
<EyeOff size={16} strokeWidth={2.5} />
</div>
<h2 className="text-sm font-bold text-gray-800">شمارش کور (Blind Counting)</h2>
</div>
<p className="text-xs text-gray-500 leading-relaxed">
در صورت فعال بودن، انبارگردانها موجودی فعلی سیستم را نمیبینند و مجبورند به جای تایید کورکورانه، کالاها را به صورت واقعی بشمارند.
</p>
</div>
{/* Elegant Switch Button */}
<button
onClick={() => setSettings(s => ({ ...s, blind_counting: !s.blind_counting }))}
className={`relative inline-flex h-8 w-14 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 ${settings.blind_counting ? 'bg-indigo-600' : 'bg-gray-200'}`}
role="switch"
aria-checked={settings.blind_counting}
>
<span
className={`pointer-events-none inline-block h-6 w-6 transform rounded-full bg-white shadow-md ring-0 transition-transform duration-300 ${settings.blind_counting ? '-translate-x-6' : 'translate-x-0'}`}
/>
</button>
</div>
{/* Correction Roles Setting */}
<div> <div>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<div className="w-8 h-8 rounded-xl bg-purple-50 text-purple-600 flex items-center justify-center"> <div className="w-8 h-8 rounded-[12px] bg-blue-50 text-blue-600 flex items-center justify-center shrink-0">
<EyeOff size={18} /> <ShieldCheck size={16} strokeWidth={2.5} />
</div> </div>
<h2 className="text-base font-bold text-gray-800">شمارش کور (Blind Counting)</h2> <h2 className="text-sm font-bold text-gray-800">دسترسی ثبت اصلاحیه</h2>
</div> </div>
<p className="text-xs text-gray-500 leading-relaxed max-w-lg"> <p className="text-xs text-gray-500 leading-relaxed mb-5">
در صورت فعال بودن، انبارگردانها موجودی فعلی سیستم را نمیبینند و مجبورند به جای تایید کورکورانه، کالاها را به صورت واقعی بشمارند. وقتی انبارگردانی یک قفسه بسته میشود، چه نقشهایی اجازه دارند درخواست اصلاحیه ثبت کنند؟
</p> </p>
</div>
<button
onClick={() => setSettings(s => ({ ...s, blind_counting: !s.blind_counting }))}
className={`relative inline-flex h-7 w-14 items-center rounded-full transition-colors ${settings.blind_counting ? 'bg-indigo-600' : 'bg-gray-200'}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform shadow-sm ${settings.blind_counting ? '-translate-x-1.5' : '-translate-x-8'}`} />
</button>
</div>
{/* Correction Roles Setting */} <div className="grid grid-cols-2 gap-3">
<div> {availableRoles.map(role => {
<div className="flex items-center gap-2 mb-2"> const isSelected = settings.correction_roles?.includes(role.id);
<div className="w-8 h-8 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center"> return (
<ShieldCheck size={18} /> <motion.button
</div> whileTap={{ scale: 0.97 }}
<h2 className="text-base font-bold text-gray-800">دسترسی ثبت اصلاحیه</h2> key={role.id}
</div> onClick={() => toggleRole(role.id)}
<p className="text-xs text-gray-500 leading-relaxed mb-4"> className={`flex items-center justify-between p-3.5 rounded-[16px] border text-xs font-bold transition-all ${
وقتی انبارگردانی یک قفسه بسته میشود، چه نقشهایی اجازه دارند درخواست اصلاحیه ثبت کنند؟ (حالت چندانتخابی) isSelected
</p> ? 'border-indigo-500 bg-indigo-50 text-indigo-700 shadow-sm'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50'
<div className="grid grid-cols-2 md:grid-cols-4 gap-3"> }`}
{availableRoles.map(role => { >
const isSelected = settings.correction_roles?.includes(role.id); {role.label}
return ( <div className={`w-5 h-5 rounded-full flex items-center justify-center transition-colors ${isSelected ? 'bg-indigo-500 text-white' : 'bg-gray-100 text-transparent'}`}>
<button <Check size={12} strokeWidth={3} />
key={role.id} </div>
onClick={() => toggleRole(role.id)} </motion.button>
className={`flex items-center justify-between p-4 rounded-[16px] border text-xs font-bold transition-all ${ );
isSelected })}
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'
}`}
>
{role.label}
{isSelected && <Check size={14} />}
</button>
);
})}
</div>
</div>
{/* Warehouses Setting */}
<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-xl bg-orange-50 text-orange-600 flex items-center justify-center">
<span className="font-black text-sm">WH</span>
</div>
<h2 className="text-base font-bold text-gray-800">مدیریت انبارهای حسابفا</h2>
</div>
<p className="text-xs text-gray-500 leading-relaxed mb-4">
لیست انبارهایی که انبارداران مجاز به انبارگردانی آنها هستند. کد انبار باید دقیقاً با کد حسابفا مطابقت داشته باشد.
</p>
<div className="flex flex-col gap-3">
{settings.warehouses?.map((wh) => (
<div key={wh.id} className="flex items-center justify-between p-3 rounded-[16px] border border-gray-200 bg-gray-50">
<div className="flex items-center gap-3">
<div className="bg-gray-200 text-gray-600 px-3 py-1 rounded-lg text-xs font-black">کد: {wh.id}</div>
<span className="text-sm font-bold text-gray-800">{wh.name}</span>
</div>
<button
onClick={() => setSettings(s => ({ ...s, warehouses: s.warehouses.filter(w => w.id !== wh.id) }))}
className="text-red-500 hover:bg-red-50 p-2 rounded-xl transition-colors"
>
حذف
</button>
</div>
))}
<div className="flex gap-2 mt-2">
<input
type="number"
placeholder="کد انبار..."
id="wh_id"
className="w-24 bg-white border border-gray-200 rounded-[16px] px-3 py-2 text-sm focus:outline-none focus:border-indigo-500"
/>
<input
type="text"
placeholder="نام انبار..."
id="wh_name"
className="flex-1 bg-white border border-gray-200 rounded-[16px] px-3 py-2 text-sm focus:outline-none focus:border-indigo-500"
/>
<button
onClick={() => {
const id = document.getElementById('wh_id').value;
const name = document.getElementById('wh_name').value;
if (id && name) {
setSettings(s => ({
...s,
warehouses: [...(s.warehouses || []), { id, name }]
}));
document.getElementById('wh_id').value = '';
document.getElementById('wh_name').value = '';
}
}}
className="bg-indigo-50 text-indigo-600 px-4 py-2 rounded-[16px] text-xs font-bold hover:bg-indigo-100 transition-colors"
>
افزودن
</button>
</div> </div>
</div> </div>
</div> </div>
</div>
<div className="flex justify-end">
<motion.button <motion.button
whileTap={{ scale: 0.97 }} whileTap={{ scale: 0.98 }}
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
className="flex items-center gap-2 bg-gray-900 text-white px-8 py-4 rounded-[20px] text-sm font-bold shadow-md hover:bg-gray-800 transition-colors disabled:opacity-70" className="w-full mt-4 flex items-center justify-center gap-2 bg-gray-900 text-white px-6 py-4 rounded-[16px] text-sm font-extrabold shadow-md hover:bg-gray-800 transition-colors disabled:opacity-70"
> >
{saving ? ( {saving ? (
<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>
) : saved ? (
<>
<Check size={18} />
ذخیره شد
</>
) : ( ) : (
<> <>
<Save size={18} /> <Save size={18} strokeWidth={2.5} />
ذخیره تغییرات ذخیره تغییرات
</> </>
)} )}
</motion.button> </motion.button>
</div> </div>
<AnimatePresence>
{toast.show && (
<motion.div
initial={{ opacity: 0, y: 50, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.9 }}
className={`fixed bottom-8 left-1/2 -translate-x-1/2 z-[100] px-5 py-3 rounded-2xl shadow-xl backdrop-blur-3xl border text-xs font-bold whitespace-nowrap flex items-center justify-center gap-2 ${toast.isError ? 'bg-red-50/90 border-red-100 text-red-600' : 'bg-white/90 border-gray-100 text-gray-800'}`}
>
{toast.isError ? <XCircle size={14} /> : <Check size={14} />}
{toast.message}
</motion.div>
)}
</AnimatePresence>
</div> </div>
); );
} }
+205
View File
@@ -0,0 +1,205 @@
'use client';
import { useState, useEffect } from 'react';
import Header from '@/components/Header';
import { motion, AnimatePresence } from 'framer-motion';
import { Users, Edit2, Check, Shield, User as UserIcon, X } from 'lucide-react';
export default function UsersPage() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [editingUser, setEditingUser] = useState(null);
const [saving, setSaving] = useState(false);
const availableRoles = [
{ id: 'ADMIN', label: 'مدیر کل' },
{ id: 'SUPERVISOR', label: 'سرپرست انبار' },
{ id: 'ACCOUNTANT', label: 'حسابدار' },
{ id: 'COUNTER', label: 'انبارگردان' }
];
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
const res = await fetch('/api/admin/users');
if (res.ok) {
setUsers(await res.json());
}
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
const handleSaveRoles = async () => {
if (!editingUser) return;
setSaving(true);
try {
const res = await fetch(`/api/admin/users/${editingUser.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roles: editingUser.roles })
});
if (res.ok) {
fetchUsers();
setEditingUser(null);
}
} catch (e) {
console.error(e);
} finally {
setSaving(false);
}
};
const toggleRole = (roleId) => {
setEditingUser(prev => {
const roles = prev.roles || [];
if (roles.includes(roleId)) {
return { ...prev, roles: roles.filter(r => r !== roleId) };
} else {
return { ...prev, roles: [...roles, roleId] };
}
});
};
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 (
<div className="w-full min-h-screen bg-gray-50 flex flex-col pb-24 relative overflow-x-hidden">
<Header title="مدیریت کاربران" showBack={true} />
<div className="flex-1 p-5 flex flex-col gap-6 max-w-2xl mx-auto w-full mt-2">
<div className="flex flex-col">
<h2 className="text-xl font-black text-gray-800 tracking-tight">لیست کاربران سیستم</h2>
<p className="text-xs text-gray-400 font-medium mt-1">مدیریت دسترسیها و بررسی فعالیت کارکنان</p>
</div>
<div className="flex flex-col gap-3">
{users.map(user => {
const userRoles = user.roles || [];
return (
<motion.div
key={user.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white p-4 rounded-[20px] border border-gray-100 shadow-sm flex flex-col gap-3"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-50 text-indigo-600 rounded-[14px] flex items-center justify-center shrink-0">
<UserIcon size={18} strokeWidth={2.5} />
</div>
<div className="flex flex-col">
<span className="font-bold text-gray-800 text-sm">{user.name || user.username}</span>
<span className="text-[10px] text-gray-400 font-medium mt-0.5" dir="ltr">{user.username}</span>
</div>
</div>
<button
onClick={() => setEditingUser(user)}
className="w-8 h-8 flex items-center justify-center rounded-xl bg-gray-50 text-gray-500 hover:bg-indigo-50 hover:text-indigo-600 transition-colors"
>
<Edit2 size={14} strokeWidth={2.5} />
</button>
</div>
<div className="flex items-center justify-between pt-3 border-t border-gray-50">
<div className="flex gap-1 flex-wrap">
{userRoles.map(r => {
const label = availableRoles.find(ar => ar.id === r)?.label || r;
return <span key={r} className="bg-blue-50 text-blue-600 px-2 py-0.5 rounded text-[10px] font-bold">{label}</span>;
})}
{userRoles.length === 0 && <span className="text-[10px] text-gray-400">بدون نقش</span>}
</div>
<div className="text-[10px] font-bold text-gray-500 bg-gray-50 px-2 py-1 rounded-lg">
{user._count?.countings || 0} شمارش
</div>
</div>
</motion.div>
);
})}
</div>
</div>
{/* Edit Modal */}
<AnimatePresence>
{editingUser && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setEditingUser(null)}
className="absolute inset-0 bg-gray-900/40 backdrop-blur-sm"
/>
<motion.div
initial={{ scale: 0.95, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.95, opacity: 0, y: 20 }}
className="w-full max-w-sm bg-white rounded-[24px] shadow-2xl relative z-10 flex flex-col overflow-hidden"
>
<div className="p-5 border-b border-gray-100 flex items-center justify-between bg-gray-50/50">
<div className="flex items-center gap-2">
<Shield size={18} className="text-indigo-600" />
<span className="font-bold text-gray-800 text-sm">ویرایش دسترسیها</span>
</div>
<button onClick={() => setEditingUser(null)} className="text-gray-400 hover:text-gray-600 transition-colors">
<X size={20} />
</button>
</div>
<div className="p-5 flex flex-col gap-4">
<div className="text-sm font-bold text-gray-700 text-center mb-2">
{editingUser.name || editingUser.username}
</div>
<div className="grid grid-cols-2 gap-3">
{availableRoles.map(role => {
const isSelected = editingUser.roles?.includes(role.id);
return (
<motion.button
whileTap={{ scale: 0.97 }}
key={role.id}
onClick={() => toggleRole(role.id)}
className={`flex items-center justify-between p-3 rounded-[16px] border text-xs font-bold transition-all ${
isSelected
? 'border-indigo-500 bg-indigo-50 text-indigo-700'
: 'border-gray-200 bg-white text-gray-600'
}`}
>
{role.label}
<div className={`w-4 h-4 rounded-full flex items-center justify-center transition-colors ${isSelected ? 'bg-indigo-500 text-white' : 'bg-gray-100 text-transparent'}`}>
<Check size={10} strokeWidth={3} />
</div>
</motion.button>
);
})}
</div>
<motion.button
whileTap={{ scale: 0.98 }}
onClick={handleSaveRoles}
disabled={saving}
className="w-full mt-2 flex items-center justify-center gap-2 bg-gray-900 text-white px-4 py-3.5 rounded-[16px] text-sm font-bold shadow-md disabled:opacity-70 transition-colors hover:bg-gray-800"
>
{saving ? <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> : 'ذخیره دسترسیها'}
</motion.button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}
+210
View File
@@ -0,0 +1,210 @@
'use client';
import { useState, useEffect } from 'react';
import Header from '@/components/Header';
import { motion, AnimatePresence } from 'framer-motion';
import { Plus, Trash2, Building2, AlertCircle, XCircle, Check } from 'lucide-react';
export default function WarehousesPage() {
const [warehouses, setWarehouses] = useState([]);
const [newCode, setNewCode] = useState('');
const [newName, setNewName] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [toast, setToast] = useState({ show: false, message: '', isError: false });
const showToast = (message, isError = false) => {
setToast({ show: true, message, isError });
setTimeout(() => setToast({ show: false, message: '', isError: false }), 3000);
};
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const res = await fetch('/api/settings');
if (res.ok) {
const data = await res.json();
setWarehouses(data.warehouses || []);
}
} catch (error) {
console.error(error);
showToast('خطا در دریافت لیست انبارها', true);
} finally {
setLoading(false);
}
};
const saveWarehouses = async (newWarehouses) => {
setSaving(true);
try {
const res = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ warehouses: newWarehouses })
});
if (res.ok) {
setWarehouses(newWarehouses);
showToast('انبارها با موفقیت ذخیره شدند');
} else {
showToast('خطا در ذخیره انبار', true);
}
} catch (error) {
console.error(error);
showToast('خطای شبکه', true);
} finally {
setSaving(false);
}
};
const handleAdd = () => {
if (!newCode || !newName) {
showToast('لطفاً کد و نام انبار را وارد کنید', true);
return;
}
// Convert to number strictly if Hesabfa requires int, but string is safer and compatible
const codeId = parseInt(newCode);
if(isNaN(codeId)) {
showToast('کد انبار باید عدد باشد', true);
return;
}
if (warehouses.find(w => parseInt(w.id) === codeId)) {
showToast('انباری با این کد قبلاً ثبت شده است', true);
return;
}
const updated = [...warehouses, { id: codeId.toString(), name: newName }];
saveWarehouses(updated);
setNewCode('');
setNewName('');
};
const handleDelete = (id) => {
if (!confirm('آیا از حذف این انبار اطمینان دارید؟')) return;
const updated = warehouses.filter(w => w.id !== id);
saveWarehouses(updated);
};
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-orange-500 border-t-transparent rounded-full animate-spin"></div>
</div>
</div>
);
}
return (
<div className="w-full min-h-screen bg-gray-50 flex flex-col pb-24 relative overflow-x-hidden">
<Header title="مدیریت انبارها" showBack={true} />
<div className="flex-1 p-5 flex flex-col gap-6 max-w-lg mx-auto w-full mt-2">
<div className="flex flex-col">
<h2 className="text-xl font-black text-gray-800 tracking-tight">انبارهای حسابفا</h2>
<p className="text-xs text-gray-400 font-medium mt-1">مدیریت لیست انبارهایی که سیستم استعلام موجودی میگیرد</p>
</div>
<div className="bg-white rounded-[24px] p-6 shadow-sm border border-gray-100 flex flex-col gap-6">
<div className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-gray-800 flex items-center gap-2">
<div className="w-6 h-6 rounded-[10px] bg-orange-50 text-orange-600 flex items-center justify-center">
<Building2 size={14} />
</div>
ثبت انبار جدید
</h3>
<div className="flex flex-col gap-3 bg-gray-50 p-4 rounded-[16px] border border-gray-100">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-gray-600 pr-1">کد انبار (در حسابفا)</label>
<input
type="number"
value={newCode}
onChange={(e) => setNewCode(e.target.value)}
placeholder="مثال: 11"
className="w-full bg-white border border-gray-200 rounded-[12px] px-4 py-3 text-sm focus:outline-none focus:border-orange-500 transition-colors"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-gray-600 pr-1">نام انبار</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="مثال: انبار مرکزی"
className="w-full bg-white border border-gray-200 rounded-[12px] px-4 py-3 text-sm focus:outline-none focus:border-orange-500 transition-colors"
/>
</div>
<motion.button
whileTap={{ scale: 0.97 }}
onClick={handleAdd}
disabled={saving}
className="w-full bg-gray-900 text-white px-4 py-3 rounded-[12px] text-sm font-bold hover:bg-gray-800 transition-colors mt-2 flex items-center justify-center gap-2 disabled:opacity-50"
>
{saving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> : <Plus size={16} strokeWidth={3} />}
افزودن به لیست
</motion.button>
</div>
</div>
<div className="border-t border-gray-100 pt-6 flex flex-col gap-4">
<h3 className="text-sm font-bold text-gray-800">لیست انبارهای فعال</h3>
{warehouses.length === 0 ? (
<div className="py-8 text-center bg-gray-50 rounded-[16px] border border-gray-100 border-dashed">
<p className="text-xs text-gray-400 font-medium">هیچ انباری ثبت نشده است.</p>
</div>
) : (
<div className="flex flex-col gap-3">
<AnimatePresence>
{warehouses.map((wh) => (
<motion.div
key={wh.id}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="flex items-center justify-between p-3.5 rounded-[16px] border border-gray-200 bg-white shadow-sm"
>
<div className="flex items-center gap-3">
<div className="bg-orange-50 text-orange-600 px-3 py-1.5 rounded-[10px] text-xs font-black tracking-wider">
{wh.id}
</div>
<span className="text-sm font-bold text-gray-800">{wh.name}</span>
</div>
<button
onClick={() => handleDelete(wh.id)}
className="text-gray-400 hover:text-red-500 hover:bg-red-50 p-2 rounded-xl transition-colors"
>
<Trash2 size={16} />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</div>
</div>
</div>
<AnimatePresence>
{toast.show && (
<motion.div
initial={{ opacity: 0, y: 50, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.9 }}
className={`fixed bottom-8 left-1/2 -translate-x-1/2 z-[100] px-5 py-3 rounded-2xl shadow-xl backdrop-blur-3xl border text-xs font-bold whitespace-nowrap flex items-center justify-center gap-2 ${toast.isError ? 'bg-red-50/90 border-red-100 text-red-600' : 'bg-white/90 border-gray-100 text-gray-800'}`}
>
{toast.isError ? <XCircle size={14} /> : <Check size={14} />}
{toast.message}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import prisma from '@/lib/prisma';
import { NextResponse } from 'next/server';
export async function PUT(req, { params }) {
try {
const { id: idParam } = await params;
const id = parseInt(idParam, 10);
const { roles } = await req.json();
const updated = await prisma.user.update({
where: { id },
data: { roles }
});
return NextResponse.json({ success: true, user: updated });
} catch (error) {
return NextResponse.json({ error: 'خطا در ویرایش کاربر' }, { status: 500 });
}
}
+18
View File
@@ -0,0 +1,18 @@
import prisma from '@/lib/prisma';
import { NextResponse } from 'next/server';
export async function GET() {
try {
const users = await prisma.user.findMany({
include: {
_count: {
select: { countings: true }
}
},
orderBy: { createdAt: 'desc' }
});
return NextResponse.json(users);
} catch (error) {
return NextResponse.json({ error: 'خطا در دریافت کاربران' }, { status: 500 });
}
}
+10
View File
@@ -26,6 +26,16 @@ export async function POST(req) {
return NextResponse.json(res.data); return NextResponse.json(res.data);
} }
if (type === 'all') {
const res = await axios.post('https://api.hesabfa.com/v1/item/getitems', {
apiKey: HESABFA_API_KEY,
loginToken: HESABFA_TOKEN,
queryInfo: { Take: 2000, Skip: 0 },
type: 0
});
return NextResponse.json(res.data);
}
return NextResponse.json({ error: 'نوع درخواست نامعتبر است' }, { status: 400 }); return NextResponse.json({ error: 'نوع درخواست نامعتبر است' }, { status: 400 });
} catch (error) { } catch (error) {
return NextResponse.json({ error: 'خطا در ارتباط با حسابفا' }, { status: 500 }); return NextResponse.json({ error: 'خطا در ارتباط با حسابفا' }, { status: 500 });
+3 -2
View File
@@ -26,7 +26,7 @@ export async function PUT(req, { params }) {
try { try {
const { id: idParam } = await params; const { id: idParam } = await params;
const id = parseInt(idParam, 10); const id = parseInt(idParam, 10);
const { code } = await req.json(); const { code, warehouse } = await req.json();
const location = await prisma.location.findUnique({ const location = await prisma.location.findUnique({
where: { id }, where: { id },
@@ -45,10 +45,11 @@ export async function PUT(req, { params }) {
const [, floor, regionStr, sector, rowStr] = match; const [, floor, regionStr, sector, rowStr] = match;
const region = parseInt(regionStr, 10); const region = parseInt(regionStr, 10);
const row = parseInt(rowStr, 10); const row = parseInt(rowStr, 10);
const warehouseId = warehouse ? parseInt(warehouse, 10) : null;
const updated = await prisma.location.update({ const updated = await prisma.location.update({
where: { id }, where: { id },
data: { code: code.toUpperCase(), floor, region, sector, row } data: { code: code.toUpperCase(), floor, region, sector, row, warehouse: warehouseId }
}); });
return NextResponse.json({ success: true, location: updated }); return NextResponse.json({ success: true, location: updated });
+5 -3
View File
@@ -15,7 +15,7 @@ export async function GET() {
export async function POST(req) { export async function POST(req) {
try { try {
const { code } = await req.json(); // "C2F2" const { code, warehouse } = await req.json(); // "C2F2", 11
// Pattern validation (e.g., C2F2 -> 1 Char, 1 Num, 1 Char, 1 Num) // Pattern validation (e.g., C2F2 -> 1 Char, 1 Num, 1 Char, 1 Num)
const regex = /^([A-Za-z]+)(\d+)([A-Za-z]+)(\d+)$/; const regex = /^([A-Za-z]+)(\d+)([A-Za-z]+)(\d+)$/;
@@ -28,16 +28,18 @@ export async function POST(req) {
const [, floor, regionStr, sector, rowStr] = match; const [, floor, regionStr, sector, rowStr] = match;
const region = parseInt(regionStr, 10); const region = parseInt(regionStr, 10);
const row = parseInt(rowStr, 10); const row = parseInt(rowStr, 10);
const warehouseId = warehouse ? parseInt(warehouse, 10) : null;
const location = await prisma.location.upsert({ const location = await prisma.location.upsert({
where: { code: code.toUpperCase() }, where: { code: code.toUpperCase() },
update: {}, // if exists, do nothing or update timestamps update: { warehouse: warehouseId }, // if exists, do nothing or update timestamps
create: { create: {
code: code.toUpperCase(), code: code.toUpperCase(),
floor, floor,
region, region,
sector, sector,
row row,
warehouse: warehouseId
} }
}); });