feat: redesign settings, add warehouse/user/product management, update location schema
This commit is contained in:
@@ -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?
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user