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
+35 -2
View File
@@ -8,6 +8,8 @@ const Scanner = dynamic(() => import('@yudiel/react-qr-scanner').then(mod => mod
export default function AdminLocations() {
const [locations, setLocations] = useState([]);
const [warehouses, setWarehouses] = useState([]);
const [selectedWarehouse, setSelectedWarehouse] = useState('');
const [newCode, setNewCode] = useState('');
const [loading, setLoading] = useState(false);
const [cameraEnabled, setCameraEnabled] = useState(false);
@@ -26,8 +28,24 @@ export default function AdminLocations() {
useEffect(() => {
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 () => {
try {
const res = await fetch('/api/locations');
@@ -42,6 +60,10 @@ export default function AdminLocations() {
const handleAddOrEdit = async (codeValue) => {
const targetCode = editingId ? editCode : (codeValue || newCode);
if (!targetCode) return;
if (!selectedWarehouse) {
showToast('لطفاً ابتدا انبار را انتخاب کنید', true);
return;
}
setLoading(true);
try {
@@ -51,7 +73,7 @@ export default function AdminLocations() {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: targetCode })
body: JSON.stringify({ code: targetCode, warehouse: selectedWarehouse })
});
const data = await res.json();
@@ -135,6 +157,17 @@ export default function AdminLocations() {
)}
</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">
<input
type="text"
@@ -239,7 +272,7 @@ export default function AdminLocations() {
<div className="flex flex-col">
<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">
طبقه {loc.floor} منطقه {loc.region} قطاع {loc.sector}
{loc.warehouse ? `انبار: ${loc.warehouse}` : ''}طبقه {loc.floor} منطقه {loc.region} قطاع {loc.sector}
</span>
</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>
);
}
+103 -131
View File
@@ -1,7 +1,8 @@
'use client';
import { useState, useEffect } from 'react';
import { Save, EyeOff, ShieldCheck, Check } from 'lucide-react';
import { motion } from 'framer-motion';
import Header from '@/components/Header';
import { Save, EyeOff, ShieldCheck, Check, AlertCircle, XCircle } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
export default function SettingsPage() {
const [settings, setSettings] = useState({
@@ -10,7 +11,7 @@ export default function SettingsPage() {
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [toast, setToast] = useState({ show: false, message: '', isError: false });
const availableRoles = [
{ id: 'ADMIN', label: 'مدیر کل' },
@@ -19,6 +20,11 @@ export default function SettingsPage() {
{ id: 'COUNTER', label: 'انبارگردان' }
];
const showToast = (message, isError = false) => {
setToast({ show: true, message, isError });
setTimeout(() => setToast({ show: false, message: '', isError: false }), 3000);
};
useEffect(() => {
fetchSettings();
}, []);
@@ -39,7 +45,6 @@ export default function SettingsPage() {
const handleSave = async () => {
setSaving(true);
setSaved(false);
try {
const res = await fetch('/api/settings', {
method: 'POST',
@@ -47,11 +52,13 @@ export default function SettingsPage() {
body: JSON.stringify(settings)
});
if (res.ok) {
setSaved(true);
setTimeout(() => setSaved(false), 3000);
showToast('تنظیمات با موفقیت ذخیره شد');
} else {
showToast('خطا در ذخیره تنظیمات', true);
}
} catch (error) {
console.error(error);
showToast('خطای شبکه', true);
} finally {
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 (
<div className="max-w-3xl mx-auto p-4 md:p-8 space-y-8">
<div>
<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="w-full min-h-screen bg-gray-50 flex flex-col pb-24 relative overflow-x-hidden">
<Header title="تنظیمات سیستم" showBack={true} />
<div className="bg-white rounded-[24px] p-6 shadow-sm border border-gray-100 space-y-8">
{/* Blind Counting Setting */}
<div className="flex items-start justify-between gap-4 border-b border-gray-50 pb-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>
<div className="w-full bg-white rounded-[24px] p-6 shadow-sm border border-gray-100 flex flex-col gap-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 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">
<EyeOff size={18} />
<div className="w-8 h-8 rounded-[12px] bg-blue-50 text-blue-600 flex items-center justify-center shrink-0">
<ShieldCheck size={16} strokeWidth={2.5} />
</div>
<h2 className="text-base font-bold text-gray-800">شمارش کور (Blind Counting)</h2>
<h2 className="text-sm font-bold text-gray-800">دسترسی ثبت اصلاحیه</h2>
</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>
</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>
<div className="flex items-center gap-2 mb-2">
<div className="w-8 h-8 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center">
<ShieldCheck size={18} />
</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="grid grid-cols-2 md:grid-cols-4 gap-3">
{availableRoles.map(role => {
const isSelected = settings.correction_roles?.includes(role.id);
return (
<button
key={role.id}
onClick={() => toggleRole(role.id)}
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 className="grid grid-cols-2 gap-3">
{availableRoles.map(role => {
const isSelected = settings.correction_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.5 rounded-[16px] border text-xs font-bold transition-all ${
isSelected
? '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'
}`}
>
{role.label}
<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'}`}>
<Check size={12} strokeWidth={3} />
</div>
</motion.button>
);
})}
</div>
</div>
</div>
</div>
<div className="flex justify-end">
<motion.button
whileTap={{ scale: 0.97 }}
whileTap={{ scale: 0.98 }}
onClick={handleSave}
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 ? (
<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>
</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>
);
}
+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>
);
}