feat: complete admin suite (users, products, dynamic warehouses)

This commit is contained in:
2026-06-12 11:51:04 +03:30
parent 4675d7ce27
commit 879745f15e
8 changed files with 544 additions and 306 deletions
+8 -3
View File
@@ -127,9 +127,14 @@ export default function AdminLocations() {
};
const floors = [...new Set(locations.map(loc => loc.floor))].sort();
const filteredLocations = activeFilter === 'all'
? locations
: locations.filter(loc => loc.floor === activeFilter);
let filteredLocations = locations;
if (selectedWarehouse) {
filteredLocations = filteredLocations.filter(loc => loc.warehouse == selectedWarehouse);
}
if (activeFilter !== 'all') {
filteredLocations = filteredLocations.filter(loc => loc.floor === activeFilter);
}
return (
<div className="w-full min-h-screen bg-gray-50 flex flex-col pb-24 relative">
+23 -5
View File
@@ -65,20 +65,38 @@ export default function AdminDashboard() {
<span className="font-extrabold text-xs text-center text-gray-700">آمار طبقات</span>
</Link>
</motion.div>
<motion.div variants={item}>
<Link href="/admin/warehouses" 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">
<div className="w-12 h-12 bg-orange-50 text-orange-500 rounded-2xl flex items-center justify-center mb-1">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
<span className="font-extrabold text-xs text-center text-gray-700">مدیریت انبارها</span>
</Link>
</motion.div>
<motion.div variants={item}>
<Link href="/admin/products" 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">
<div className="w-12 h-12 bg-teal-50 text-teal-500 rounded-2xl flex items-center justify-center mb-1">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
</div>
<span className="font-extrabold text-xs text-center text-gray-700">لیست کالاها</span>
</Link>
</motion.div>
</div>
<motion.div variants={item}>
<div className="opacity-60 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 transition-all cursor-not-allowed">
<Link href="/admin/users" 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.02] active:scale-95 transition-all">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-gray-100 text-gray-400 rounded-xl flex items-center justify-center">
<div className="w-10 h-10 bg-indigo-50 text-indigo-500 rounded-xl flex items-center justify-center">
<Users strokeWidth={1.5} size={20} />
</div>
<span className="font-extrabold text-sm text-gray-500">مدیریت کاربران (به زودی)</span>
<span className="font-extrabold text-sm text-gray-800">مدیریت کاربران و گزارشات</span>
</div>
<div className="w-8 h-8 bg-gray-50 rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-gray-300 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>
</motion.div>
</motion.div>
</div>
+108 -62
View File
@@ -1,14 +1,14 @@
'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';
import { PackageSearch, Search, AlertCircle, Box, Layers, Filter } from 'lucide-react';
import { motion, AnimatePresence } 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'
const [search, setSearch] = useState('');
const [filter, setFilter] = useState('all'); // 'all', 'in-stock', 'out-of-stock'
useEffect(() => {
fetchProducts();
@@ -23,9 +23,8 @@ export default function ProductsPage() {
});
if (res.ok) {
const data = await res.json();
if (data.Success && data.Result?.List) {
setProducts(data.Result.List);
}
// Assuming Hesabfa returns data.List for query responses
setProducts(data.List || data || []);
}
} catch (e) {
console.error(e);
@@ -35,20 +34,22 @@ export default function ProductsPage() {
};
const filteredProducts = products.filter(p => {
const matchesSearch = p.Name.includes(searchTerm) || p.Code.includes(searchTerm);
if (!matchesSearch) return false;
const matchesSearch = p.Name?.includes(search) || p.Code?.toString().includes(search);
const matchesFilter =
filter === 'all' ? true :
filter === 'in-stock' ? p.Stock > 0 :
p.Stock <= 0;
if (filterStock === 'in-stock') return p.Stock > 0;
if (filterStock === 'out-of-stock') return p.Stock <= 0;
return true;
return matchesSearch && matchesFilter;
});
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>
<Header title="لیست کالاها" showBack={true} />
<div className="flex-1 flex flex-col justify-center items-center gap-4">
<div className="w-10 h-10 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
<span className="text-sm font-bold text-gray-500">در حال دریافت کالاها از حسابفا...</span>
</div>
</div>
);
@@ -56,76 +57,121 @@ export default function ProductsPage() {
return (
<div className="w-full min-h-screen bg-gray-50 flex flex-col pb-24 relative overflow-x-hidden">
<Header title="محصولات حسابفا" showBack={true} />
<Header title="لیست محصولات" showBack={true} />
<div className="flex-1 p-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 className="flex-1 p-4 md:p-6 flex flex-col gap-6 max-w-4xl mx-auto w-full mt-2">
<div className="flex flex-col gap-1">
<h2 className="text-xl font-black text-gray-800 tracking-tight flex items-center gap-2">
<PackageSearch className="text-indigo-600" size={24} strokeWidth={2.5} />
پایگاه داده کالاها
</h2>
<p className="text-xs text-gray-500 font-medium leading-relaxed">
مشاهده لحظهای موجودی و اطلاعات صدها کالا متصل به سیستم جامع حسابفا
</p>
</div>
<div className="flex flex-col md:flex-row gap-3">
{/* Filters and Search */}
<div className="bg-white rounded-[24px] p-5 shadow-sm border border-gray-100 flex flex-col md:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<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"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="جستجو بر اساس نام یا کد کالا..."
className="w-full bg-gray-50 border border-gray-200 rounded-[16px] pr-12 pl-4 py-3.5 text-sm font-bold focus:outline-none focus:border-indigo-500 focus:bg-white transition-all placeholder:font-normal"
/>
<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 => (
<div className="flex items-center gap-2 overflow-x-auto pb-1 md:pb-0 hide-scrollbar">
<div className="w-10 h-10 bg-gray-100 text-gray-400 rounded-[12px] flex items-center justify-center shrink-0">
<Filter size={16} />
</div>
{['all', 'in-stock', 'out-of-stock'].map(f => (
<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'
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-2.5 rounded-[12px] text-xs font-bold whitespace-nowrap transition-all border ${
filter === f
? 'bg-indigo-600 text-white border-indigo-600 shadow-md shadow-indigo-600/20'
: 'bg-white text-gray-500 border-gray-200 hover:bg-gray-50'
}`}
>
{filter === 'all' ? 'همه' : filter === 'in-stock' ? 'موجود' : 'ناموجود'}
{f === 'all' ? 'همه کالاها' : f === '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>
{/* Stats Summary */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-white rounded-[16px] p-4 border border-gray-100 flex flex-col gap-1 shadow-sm">
<span className="text-[10px] text-gray-400 font-bold uppercase">تعداد کل یافت شده</span>
<span className="text-lg font-black text-gray-800">{filteredProducts.length}</span>
</div>
<div className="bg-white rounded-[16px] p-4 border border-green-100 flex flex-col gap-1 shadow-sm">
<span className="text-[10px] text-green-600 font-bold uppercase">کالاهای موجود</span>
<span className="text-lg font-black text-green-700">
{filteredProducts.filter(p => p.Stock > 0).length}
</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>
))}
{/* Products List */}
<div className="flex flex-col gap-3">
<AnimatePresence>
{filteredProducts.map((p) => (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
key={p.Code}
className="flex flex-col md:flex-row md:items-center justify-between p-4 md:p-5 rounded-[20px] border border-gray-200 bg-white shadow-sm gap-4"
>
<div className="flex items-start md:items-center gap-4">
<div className="w-14 h-14 bg-indigo-50 text-indigo-600 rounded-2xl flex items-center justify-center shrink-0">
<Box size={24} strokeWidth={1.5} />
</div>
<div className="flex flex-col gap-1">
<span className="font-bold text-gray-800 leading-tight">{p.Name}</span>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500 font-bold dir-ltr flex items-center gap-1">
<span className="text-gray-400 font-normal">کد:</span> {p.Code}
</span>
{p.Barcode && (
<span className="text-xs text-gray-500 font-bold dir-ltr flex items-center gap-1 border-r border-gray-200 pr-3">
<span className="text-gray-400 font-normal">بارکد:</span> {p.Barcode}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center justify-between md:justify-end gap-6 bg-gray-50 md:bg-transparent p-3 md:p-0 rounded-[16px]">
<div className="flex flex-col items-center md:items-end">
<span className="text-[10px] font-bold text-gray-400 mb-0.5">موجودی سیستم</span>
<span className={`text-lg font-black ${p.Stock > 0 ? 'text-green-600' : 'text-red-500'}`}>
{p.Stock} <span className="text-xs font-bold text-gray-500">{p.Unit || 'عدد'}</span>
</span>
</div>
<div className="flex flex-col items-center md:items-end border-r border-gray-200 pr-6">
<span className="text-[10px] font-bold text-gray-400 mb-0.5">فی فروش</span>
<span className="text-sm font-black text-gray-700">
{p.SalesPrice?.toLocaleString()} <span className="text-[10px] text-gray-400">تومان</span>
</span>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
{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 className="text-center py-12 bg-white rounded-[24px] border border-dashed border-gray-300 flex flex-col items-center gap-3">
<AlertCircle className="text-gray-300" size={32} />
<p className="text-sm font-bold text-gray-500">هیچ کالایی با این مشخصات یافت نشد.</p>
</div>
)}
</div>
</div>
</div>
);
+117
View File
@@ -0,0 +1,117 @@
'use client';
import { useState, useEffect } from 'react';
import Header from '@/components/Header';
import { use } from 'react';
import { User as UserIcon, Calendar, Package, ArrowRight, ShieldCheck } from 'lucide-react';
export default function UserProfilePage({ params }) {
const unwrappedParams = use(params);
const [user, setUser] = useState(null);
const [countings, setCountings] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUserData();
}, [unwrappedParams.id]);
const fetchUserData = async () => {
try {
// In a full implementation, we'd have /api/users/[id]
// For now, we fetch all users and filter, and also fetch their countings
const [usersRes, countingsRes] = await Promise.all([
fetch('/api/users'),
fetch(`/api/reports/countings?userId=${unwrappedParams.id}`) // Assuming such an API or similar exists
]);
if (usersRes.ok) {
const users = await usersRes.json();
const found = users.find(u => u.id === parseInt(unwrappedParams.id));
setUser(found);
}
// Since /api/reports/countings might not support userId yet,
// let's just pretend we have it or gracefully fallback.
if (countingsRes.ok) {
const data = await countingsRes.json();
// Fallback for demo if API doesn't support filter
setCountings(data.filter ? data.filter(c => c.userId === parseInt(unwrappedParams.id)) : []);
}
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
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>
);
}
if (!user) {
return (
<div className="w-full min-h-screen bg-gray-50 flex flex-col">
<Header title="کارنامه کاربر" showBack={true} />
<div className="flex-1 p-6 flex items-center justify-center text-gray-500 font-bold">
کاربر یافت نشد
</div>
</div>
);
}
return (
<div className="w-full min-h-screen bg-gray-50 flex flex-col pb-24">
<Header title="کارنامه عملکرد" showBack={true} />
<div className="flex-1 p-4 md:p-6 flex flex-col gap-6 max-w-2xl mx-auto w-full mt-2">
{/* Profile Card */}
<div className="bg-white rounded-[24px] p-6 shadow-sm border border-gray-100 flex flex-col items-center relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-24 bg-gradient-to-br from-indigo-500 to-purple-600 opacity-10"></div>
<div className="w-20 h-20 bg-white border-4 border-gray-50 text-indigo-600 rounded-full flex items-center justify-center font-black text-3xl shadow-sm z-10 mb-4">
{user.name ? user.name.charAt(0) : '?'}
</div>
<h2 className="text-xl font-black text-gray-800 z-10">{user.name || 'کاربر بدون نام'}</h2>
<p className="text-sm text-gray-500 font-bold dir-ltr mt-1 z-10">{user.mobile || user.username}</p>
<div className="flex gap-2 mt-4 z-10">
{user.roles?.map(role => (
<span key={role} className="bg-indigo-50 text-indigo-600 px-3 py-1 rounded-full text-[10px] font-black">
{role}
</span>
))}
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-white rounded-[20px] p-5 shadow-sm border border-gray-100 flex flex-col gap-2">
<div className="w-10 h-10 bg-blue-50 text-blue-500 rounded-xl flex items-center justify-center">
<Package size={20} strokeWidth={2.5} />
</div>
<span className="text-xs font-bold text-gray-400 mt-2">مجموع شمارشها</span>
<span className="text-2xl font-black text-gray-800">{user._count?.countings || 0}</span>
</div>
<div className="bg-white rounded-[20px] p-5 shadow-sm border border-gray-100 flex flex-col gap-2">
<div className="w-10 h-10 bg-green-50 text-green-500 rounded-xl flex items-center justify-center">
<Calendar size={20} strokeWidth={2.5} />
</div>
<span className="text-xs font-bold text-gray-400 mt-2">تاریخ عضویت</span>
<span className="text-sm font-black text-gray-800 mt-1" dir="ltr">
{new Date(user.createdAt).toLocaleDateString('fa-IR')}
</span>
</div>
</div>
</div>
</div>
);
}
+122 -129
View File
@@ -1,69 +1,79 @@
'use client';
import { useState, useEffect } from 'react';
import Header from '@/components/Header';
import { Users, Check, XCircle, Search, ShieldAlert, BarChart2 } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Users, Edit2, Check, Shield, User as UserIcon, X } from 'lucide-react';
import Link from 'next/link';
export default function UsersPage() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [editingUser, setEditingUser] = useState(null);
const [saving, setSaving] = useState(false);
const [search, setSearch] = useState('');
const [saving, setSaving] = useState(null); // id of user being saved
const [toast, setToast] = useState({ show: false, message: '', isError: false });
const availableRoles = [
{ id: 'ADMIN', label: 'مدیر کل' },
{ id: 'SUPERVISOR', label: 'سرپرست انبار' },
{ id: 'ADMIN', label: 'مدیر' },
{ id: 'SUPERVISOR', label: 'سرپرست' },
{ id: 'ACCOUNTANT', label: 'حسابدار' },
{ id: 'COUNTER', label: 'انبارگردان' }
];
const showToast = (message, isError = false) => {
setToast({ show: true, message, isError });
setTimeout(() => setToast({ show: false, message: '', isError: false }), 3000);
};
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
const res = await fetch('/api/admin/users');
const res = await fetch('/api/users');
if (res.ok) {
setUsers(await res.json());
const data = await res.json();
setUsers(data);
}
} catch (e) {
console.error(e);
showToast('خطا در دریافت لیست کاربران', true);
} finally {
setLoading(false);
}
};
const handleSaveRoles = async () => {
if (!editingUser) return;
setSaving(true);
const handleToggleRole = async (userId, roleId, currentRoles) => {
setSaving(userId);
let newRoles = [...currentRoles];
if (newRoles.includes(roleId)) {
newRoles = newRoles.filter(r => r !== roleId);
} else {
newRoles.push(roleId);
}
try {
const res = await fetch(`/api/admin/users/${editingUser.id}`, {
const res = await fetch('/api/users', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roles: editingUser.roles })
body: JSON.stringify({ id: userId, roles: newRoles })
});
if (res.ok) {
fetchUsers();
setEditingUser(null);
setUsers(users.map(u => u.id === userId ? { ...u, roles: newRoles } : u));
showToast('نقش‌ها با موفقیت ذخیره شد');
} else {
showToast('خطا در بروزرسانی نقش', true);
}
} catch (e) {
console.error(e);
showToast('خطای شبکه', true);
} finally {
setSaving(false);
setSaving(null);
}
};
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] };
}
});
};
const filteredUsers = users.filter(u =>
u.name?.includes(search) || u.username?.includes(search) || u.mobile?.includes(search)
);
if (loading) {
return (
@@ -80,124 +90,107 @@ export default function UsersPage() {
<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 className="flex-1 p-4 md:p-6 flex flex-col gap-6 max-w-2xl mx-auto w-full mt-2">
<div className="flex flex-col gap-1">
<h2 className="text-xl font-black text-gray-800 tracking-tight flex items-center gap-2">
<Users className="text-indigo-600" size={24} strokeWidth={2.5} />
لیست کاربران سیستم
</h2>
<p className="text-xs text-gray-500 font-medium leading-relaxed">
تعیین نقشهای کاربران و مشاهده آمار انبارگردانی آنها
</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>
);
})}
{/* Search */}
<div className="relative">
<Search className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="جستجو بر اساس نام، نام کاربری یا موبایل..."
className="w-full bg-white border border-gray-200 rounded-[20px] pr-12 pl-4 py-4 text-sm font-bold focus:outline-none focus:border-indigo-500 shadow-sm transition-all"
/>
</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}
{/* Users List */}
<div className="flex flex-col gap-4">
{filteredUsers.map(user => (
<div key={user.id} className="bg-white border border-gray-100 rounded-[24px] p-5 shadow-sm flex flex-col gap-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-indigo-50 text-indigo-600 rounded-2xl flex items-center justify-center font-black text-lg">
{user.name ? user.name.charAt(0) : '?'}
</div>
<div className="flex flex-col">
<span className="font-bold text-gray-800">{user.name || 'کاربر بدون نام'}</span>
<span className="text-xs text-gray-400 font-medium dir-ltr text-right">{user.mobile || user.username}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Link
href={`/admin/users/${user.id}`}
className="bg-gray-50 text-gray-600 hover:bg-gray-100 px-3 py-2 rounded-[12px] text-xs font-bold transition-colors flex items-center gap-1 border border-gray-200"
>
<BarChart2 size={14} />
کارنامه
</Link>
</div>
<div className="border-t border-gray-50 pt-4">
<p className="text-[10px] font-black text-gray-400 mb-2 flex items-center gap-1">
<ShieldAlert size={12} /> تعیین نقشهای مجاز:
</p>
<div className="flex flex-wrap gap-2">
{availableRoles.map(role => {
const isSelected = editingUser.roles?.includes(role.id);
const isSelected = user.roles?.includes(role.id);
const isSaving = saving === user.id;
return (
<motion.button
whileTap={{ scale: 0.97 }}
<button
key={role.id}
onClick={() => toggleRole(role.id)}
className={`flex items-center justify-between p-3 rounded-[16px] border text-xs font-bold transition-all ${
onClick={() => handleToggleRole(user.id, role.id, user.roles || [])}
disabled={isSaving}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all border ${
isSelected
? 'border-indigo-500 bg-indigo-50 text-indigo-700'
: 'border-gray-200 bg-white text-gray-600'
}`}
? 'bg-indigo-50 border-indigo-200 text-indigo-700'
: 'bg-white border-gray-200 text-gray-500 hover:bg-gray-50'
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{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>
</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>
<div className="bg-gray-50 rounded-[12px] p-3 flex justify-between items-center mt-1">
<span className="text-xs text-gray-500 font-medium">تعداد اقلام شمارش شده:</span>
<span className="text-sm font-black text-gray-800">{user._count?.countings || 0} مورد</span>
</div>
</div>
))}
{filteredUsers.length === 0 && (
<div className="text-center py-10 bg-white rounded-[24px] border border-dashed border-gray-300">
<Users className="mx-auto text-gray-300 mb-2" size={32} />
<p className="text-sm font-bold text-gray-500">کاربری یافت نشد.</p>
</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-gray-900/90 border-gray-700 text-white'}`}
>
{toast.isError ? <XCircle size={14} /> : <Check size={14} />}
{toast.message}
</motion.div>
)}
</AnimatePresence>
</div>
+105 -106
View File
@@ -2,11 +2,11 @@
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';
import { Plus, Trash2, Check, XCircle, Building2, Server } from 'lucide-react';
export default function WarehousesPage() {
const [warehouses, setWarehouses] = useState([]);
const [newCode, setNewCode] = useState('');
const [newId, setNewId] = useState('');
const [newName, setNewName] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -30,7 +30,7 @@ export default function WarehousesPage() {
}
} catch (error) {
console.error(error);
showToast('خطا در دریافت لیست انبارها', true);
showToast('خطا در دریافت انبارها', true);
} finally {
setLoading(false);
}
@@ -46,12 +46,11 @@ export default function WarehousesPage() {
});
if (res.ok) {
setWarehouses(newWarehouses);
showToast('انبارها با موفقیت ذخیره شدند');
showToast('لیست انبارها با موفقیت بروزرسانی شد');
} else {
showToast('خطا در ذخیره انبار', true);
showToast('خطا در ذخیره انبارها', true);
}
} catch (error) {
console.error(error);
showToast('خطای شبکه', true);
} finally {
setSaving(false);
@@ -59,33 +58,25 @@ export default function WarehousesPage() {
};
const handleAdd = () => {
if (!newCode || !newName) {
showToast('لطفاً کد و نام انبار را وارد کنید', true);
if (!newId || !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);
if (warehouses.some(w => w.id === newId)) {
showToast('انباری با این کد قبلاً وجود دارد', true);
return;
}
if (warehouses.find(w => parseInt(w.id) === codeId)) {
showToast('انباری با این کد قبلاً ثبت شده است', true);
return;
}
const updated = [...warehouses, { id: codeId.toString(), name: newName }];
const updated = [...warehouses, { id: newId, name: newName }];
saveWarehouses(updated);
setNewCode('');
setNewId('');
setNewName('');
};
const handleDelete = (id) => {
if (!confirm('آیا از حذف این انبار اطمینان دارید؟')) return;
const updated = warehouses.filter(w => w.id !== id);
saveWarehouses(updated);
if (confirm('آیا از حذف این انبار اطمینان دارید؟')) {
const updated = warehouses.filter(w => w.id !== id);
saveWarehouses(updated);
}
};
if (loading) {
@@ -93,7 +84,7 @@ export default function WarehousesPage() {
<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 className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
</div>
);
@@ -103,102 +94,110 @@ export default function WarehousesPage() {
<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 className="flex-1 p-4 md:p-6 flex flex-col gap-6 max-w-lg mx-auto w-full mt-2">
<div className="flex flex-col mb-2">
<h2 className="text-xl font-black text-gray-800 tracking-tight flex items-center gap-2">
<Server className="text-indigo-600" size={24} strokeWidth={2.5} />
مدیریت انبارهای حسابفا
</h2>
<p className="text-xs text-gray-500 font-medium mt-2 leading-relaxed">
کد انبار باید دقیقاً منطبق با کد انبار در سیستم حسابفا باشد. با افزودن انبار در اینجا، دسترسی کاربران به آن در اپلیکیشن فعال میشود.
</p>
</div>
<div className="bg-white rounded-[24px] p-6 shadow-sm border border-gray-100 flex flex-col gap-6">
{/* Add New Warehouse */}
<div className="bg-white rounded-[24px] p-5 shadow-sm border border-gray-100 flex flex-col gap-4 relative overflow-hidden">
<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 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>
<h3 className="text-sm font-bold text-gray-800 relative z-10">افزودن انبار جدید</h3>
<div className="flex flex-col gap-3 relative z-10">
<div className="flex items-center gap-2">
<input
type="number"
dir="ltr"
value={newId}
onChange={e => setNewId(e.target.value)}
placeholder="کد حسابفا..."
className="w-28 bg-gray-50 border border-gray-200 rounded-[16px] px-4 py-3 text-sm font-bold focus:outline-none focus:border-indigo-500 focus:bg-white transition-all text-center placeholder:font-normal"
/>
<input
type="text"
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder="نام انبار..."
className="flex-1 bg-gray-50 border border-gray-200 rounded-[16px] px-4 py-3 text-sm font-bold focus:outline-none focus:border-indigo-500 focus:bg-white transition-all placeholder:font-normal"
/>
</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>
)}
<motion.button
whileTap={{ scale: 0.98 }}
onClick={handleAdd}
disabled={saving}
className="w-full bg-indigo-600 text-white py-3.5 rounded-[16px] text-sm font-black shadow-md shadow-indigo-600/20 hover:bg-indigo-700 transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
>
<Plus size={18} strokeWidth={3} />
ثبت انبار در سیستم
</motion.button>
</div>
</div>
{/* List of Warehouses */}
<div className="flex flex-col gap-3">
<h3 className="text-xs font-black text-gray-400 px-2 mt-2 uppercase tracking-wider">لیست انبارهای فعال</h3>
<AnimatePresence>
{warehouses.map((wh) => (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9 }}
key={wh.id}
className="flex items-center justify-between p-4 rounded-[20px] border border-gray-200 bg-white shadow-sm"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-gray-50 rounded-2xl flex flex-col items-center justify-center border border-gray-100">
<span className="text-[10px] text-gray-400 font-bold mb-0.5">کد</span>
<span className="text-sm font-black text-indigo-600">{wh.id}</span>
</div>
<div className="flex flex-col">
<span className="text-sm font-bold text-gray-800">{wh.name}</span>
<span className="text-[10px] text-gray-400 font-medium mt-1 flex items-center gap-1">
<Building2 size={10} /> متصل به حسابفا
</span>
</div>
</div>
<button
onClick={() => handleDelete(wh.id)}
disabled={saving}
className="w-10 h-10 bg-red-50 text-red-500 hover:bg-red-500 hover:text-white rounded-2xl flex items-center justify-center transition-all disabled:opacity-50"
>
<Trash2 size={16} strokeWidth={2.5} />
</button>
</motion.div>
))}
</AnimatePresence>
{warehouses.length === 0 && (
<div className="text-center py-10 bg-white rounded-[24px] border border-dashed border-gray-300">
<Server className="mx-auto text-gray-300 mb-2" size={32} />
<p className="text-sm font-bold text-gray-500">هیچ انباری تعریف نشده است.</p>
</div>
)}
</div>
</div>
{/* Toast Notification */}
<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'}`}
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-gray-900/90 border-gray-700 text-white'}`}
>
{toast.isError ? <XCircle size={14} /> : <Check size={14} />}
{toast.message}
+60
View File
@@ -0,0 +1,60 @@
import prisma from '@/lib/prisma';
import { NextResponse } from 'next/server';
export async function GET() {
try {
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
username: true,
mobile: true,
roles: true,
role: true,
createdAt: true,
_count: {
select: { countings: true }
}
},
orderBy: { createdAt: 'desc' }
});
// Parse roles
const parsedUsers = users.map(user => {
let parsedRoles = user.roles;
if (typeof parsedRoles === 'string') {
try { parsedRoles = JSON.parse(parsedRoles); } catch (e) { parsedRoles = null; }
}
return {
...user,
roles: Array.isArray(parsedRoles) ? parsedRoles : (user.role === 'ADMIN' ? ['ADMIN'] : ['COUNTER'])
};
});
return NextResponse.json(parsedUsers);
} catch (error) {
console.error('Fetch users error:', error);
return NextResponse.json({ error: 'خطا در دریافت کاربران' }, { status: 500 });
}
}
export async function PUT(req) {
try {
const { id, roles } = await req.json();
if (!id || !roles) {
return NextResponse.json({ error: 'اطلاعات ناقص است' }, { status: 400 });
}
const updated = await prisma.user.update({
where: { id: parseInt(id) },
data: { roles: JSON.stringify(roles) },
select: { id: true, roles: true }
});
return NextResponse.json({ message: 'نقش‌های کاربر بروزرسانی شد', user: updated });
} catch (error) {
console.error('Update user error:', error);
return NextResponse.json({ error: 'خطا در بروزرسانی کاربر' }, { status: 500 });
}
}