From 4675d7ce27a7d169eb5e576790461bc1b37e4080 Mon Sep 17 00:00:00 2001 From: DrMesta103 Date: Fri, 12 Jun 2026 11:44:05 +0330 Subject: [PATCH] feat: redesign settings, add warehouse/user/product management, update location schema --- prisma/schema.prisma | 1 + src/app/admin/locations/page.js | 37 +++- src/app/admin/products/page.js | 132 +++++++++++++++ src/app/admin/settings/page.js | 234 ++++++++++++-------------- src/app/admin/users/page.js | 205 ++++++++++++++++++++++ src/app/admin/warehouses/page.js | 210 +++++++++++++++++++++++ src/app/api/admin/users/[id]/route.js | 18 ++ src/app/api/admin/users/route.js | 18 ++ src/app/api/hesabfa/route.js | 10 ++ src/app/api/locations/[id]/route.js | 5 +- src/app/api/locations/route.js | 8 +- 11 files changed, 740 insertions(+), 138 deletions(-) create mode 100644 src/app/admin/products/page.js create mode 100644 src/app/admin/users/page.js create mode 100644 src/app/admin/warehouses/page.js create mode 100644 src/app/api/admin/users/[id]/route.js create mode 100644 src/app/api/admin/users/route.js diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 87e88b3..a389466 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -53,6 +53,7 @@ model Location { region Int sector String row Int + warehouse Int? countings Counting[] isLocked Boolean @default(false) lockedById Int? diff --git a/src/app/admin/locations/page.js b/src/app/admin/locations/page.js index 6b48bc0..9ec236a 100644 --- a/src/app/admin/locations/page.js +++ b/src/app/admin/locations/page.js @@ -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() { )} + +
{loc.code} - طبقه {loc.floor} • منطقه {loc.region} • قطاع {loc.sector} + {loc.warehouse ? `انبار: ${loc.warehouse} • ` : ''}طبقه {loc.floor} • منطقه {loc.region} • قطاع {loc.sector}
diff --git a/src/app/admin/products/page.js b/src/app/admin/products/page.js new file mode 100644 index 0000000..c3634ac --- /dev/null +++ b/src/app/admin/products/page.js @@ -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 ( +
+
+
+
+
+
+ ); + } + + return ( +
+
+ +
+
+

موجودی کالاها

+

لیست زنده کالاهای ثبت شده در نرم‌افزار حسابفا

+
+ +
+
+ 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" + /> + +
+
+ {['all', 'in-stock', 'out-of-stock'].map(filter => ( + + ))} +
+
+ +
+ {filteredProducts.map(p => ( + +
+
+ +
+
+ {p.Name} + {p.Code} +
+
+ +
0 ? 'bg-green-50 text-green-600 border border-green-100' : 'bg-gray-50 text-gray-500 border border-gray-100'}`}> + + {p.Stock} {p.Unit || 'عدد'} +
+
+ ))} + + {filteredProducts.length === 0 && ( +
+
+ +
+ کالایی یافت نشد +
+ )} +
+
+
+ ); +} diff --git a/src/app/admin/settings/page.js b/src/app/admin/settings/page.js index fc2f253..3f7d5a3 100644 --- a/src/app/admin/settings/page.js +++ b/src/app/admin/settings/page.js @@ -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
; + if (loading) { + return ( +
+
+
+
+
+
+ ); + } return ( -
-
-

تنظیمات سیستم

-

مدیریت قوانین انبارگردانی و دسترسی‌ها

-
+
+
-
- - {/* Blind Counting Setting */} -
+
+
+

تنظیمات اصلی

+

مدیریت قوانین انبارگردانی و دسترسی‌ها

+
+ +
+ + {/* Blind Counting Setting */} +
+
+
+
+ +
+

شمارش کور (Blind Counting)

+
+

+ در صورت فعال بودن، انبارگردان‌ها موجودی فعلی سیستم را نمی‌بینند و مجبورند به جای تایید کورکورانه، کالاها را به صورت واقعی بشمارند. +

+
+ + {/* Elegant Switch Button */} + +
+ + {/* Correction Roles Setting */}
-
- +
+
-

شمارش کور (Blind Counting)

+

دسترسی ثبت اصلاحیه

-

- در صورت فعال بودن، انبارگردان‌ها موجودی فعلی سیستم را نمی‌بینند و مجبورند به جای تایید کورکورانه، کالاها را به صورت واقعی بشمارند. +

+ وقتی انبارگردانی یک قفسه بسته می‌شود، چه نقش‌هایی اجازه دارند درخواست اصلاحیه ثبت کنند؟

-
- -
- - {/* Correction Roles Setting */} -
-
-
- -
-

دسترسی ثبت اصلاحیه

-
-

- وقتی انبارگردانی یک قفسه بسته می‌شود، چه نقش‌هایی اجازه دارند درخواست اصلاحیه ثبت کنند؟ (حالت چندانتخابی) -

- -
- {availableRoles.map(role => { - const isSelected = settings.correction_roles?.includes(role.id); - return ( - - ); - })} -
-
- - {/* Warehouses Setting */} -
-
-
- WH -
-

مدیریت انبارهای حسابفا

-
-

- لیست انبارهایی که انبارداران مجاز به انبارگردانی آن‌ها هستند. کد انبار باید دقیقاً با کد حسابفا مطابقت داشته باشد. -

- -
- {settings.warehouses?.map((wh) => ( -
-
-
کد: {wh.id}
- {wh.name} -
- -
- ))} -
- - - +
+ {availableRoles.map(role => { + const isSelected = settings.correction_roles?.includes(role.id); + return ( + 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} +
+ +
+
+ ); + })}
-
- -
{saving ? (
- ) : saved ? ( - <> - - ذخیره شد - ) : ( <> - + ذخیره تغییرات )}
+ + {toast.show && ( + + {toast.isError ? : } + {toast.message} + + )} +
); } diff --git a/src/app/admin/users/page.js b/src/app/admin/users/page.js new file mode 100644 index 0000000..25184dc --- /dev/null +++ b/src/app/admin/users/page.js @@ -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 ( +
+
+
+
+
+
+ ); + } + + return ( +
+
+ +
+
+

لیست کاربران سیستم

+

مدیریت دسترسی‌ها و بررسی فعالیت کارکنان

+
+ +
+ {users.map(user => { + const userRoles = user.roles || []; + return ( + +
+
+
+ +
+
+ {user.name || user.username} + {user.username} +
+
+ +
+ +
+
+ {userRoles.map(r => { + const label = availableRoles.find(ar => ar.id === r)?.label || r; + return {label}; + })} + {userRoles.length === 0 && بدون نقش} +
+
+ {user._count?.countings || 0} شمارش +
+
+
+ ); + })} +
+
+ + {/* Edit Modal */} + + {editingUser && ( +
+ setEditingUser(null)} + className="absolute inset-0 bg-gray-900/40 backdrop-blur-sm" + /> + +
+
+ + ویرایش دسترسی‌ها +
+ +
+ +
+
+ {editingUser.name || editingUser.username} +
+ +
+ {availableRoles.map(role => { + const isSelected = editingUser.roles?.includes(role.id); + return ( + 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} +
+ +
+
+ ); + })} +
+ + + {saving ?
: 'ذخیره دسترسی‌ها'} +
+
+
+
+ )} +
+
+ ); +} diff --git a/src/app/admin/warehouses/page.js b/src/app/admin/warehouses/page.js new file mode 100644 index 0000000..2a81569 --- /dev/null +++ b/src/app/admin/warehouses/page.js @@ -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 ( +
+
+
+
+
+
+ ); + } + + return ( +
+
+ +
+
+

انبارهای حسابفا

+

مدیریت لیست انبارهایی که سیستم استعلام موجودی می‌گیرد

+
+ +
+ +
+

+
+ +
+ ثبت انبار جدید +

+ +
+
+ + 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" + /> +
+
+ + 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" + /> +
+ + {saving ?
: } + افزودن به لیست +
+
+
+ +
+

لیست انبارهای فعال

+ + {warehouses.length === 0 ? ( +
+

هیچ انباری ثبت نشده است.

+
+ ) : ( +
+ + {warehouses.map((wh) => ( + +
+
+ {wh.id} +
+ {wh.name} +
+ +
+ ))} +
+
+ )} +
+ +
+
+ + + {toast.show && ( + + {toast.isError ? : } + {toast.message} + + )} + +
+ ); +} diff --git a/src/app/api/admin/users/[id]/route.js b/src/app/api/admin/users/[id]/route.js new file mode 100644 index 0000000..9df947e --- /dev/null +++ b/src/app/api/admin/users/[id]/route.js @@ -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 }); + } +} diff --git a/src/app/api/admin/users/route.js b/src/app/api/admin/users/route.js new file mode 100644 index 0000000..8947473 --- /dev/null +++ b/src/app/api/admin/users/route.js @@ -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 }); + } +} diff --git a/src/app/api/hesabfa/route.js b/src/app/api/hesabfa/route.js index 0840f6f..469125e 100644 --- a/src/app/api/hesabfa/route.js +++ b/src/app/api/hesabfa/route.js @@ -26,6 +26,16 @@ export async function POST(req) { 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 }); } catch (error) { return NextResponse.json({ error: 'خطا در ارتباط با حسابفا' }, { status: 500 }); diff --git a/src/app/api/locations/[id]/route.js b/src/app/api/locations/[id]/route.js index 3d11fa0..b801fba 100644 --- a/src/app/api/locations/[id]/route.js +++ b/src/app/api/locations/[id]/route.js @@ -26,7 +26,7 @@ export async function PUT(req, { params }) { try { const { id: idParam } = await params; const id = parseInt(idParam, 10); - const { code } = await req.json(); + const { code, warehouse } = await req.json(); const location = await prisma.location.findUnique({ where: { id }, @@ -45,10 +45,11 @@ export async function PUT(req, { params }) { const [, floor, regionStr, sector, rowStr] = match; const region = parseInt(regionStr, 10); const row = parseInt(rowStr, 10); + const warehouseId = warehouse ? parseInt(warehouse, 10) : null; const updated = await prisma.location.update({ 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 }); diff --git a/src/app/api/locations/route.js b/src/app/api/locations/route.js index 6cf0cfa..b5163b6 100644 --- a/src/app/api/locations/route.js +++ b/src/app/api/locations/route.js @@ -15,7 +15,7 @@ export async function GET() { export async function POST(req) { 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) 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 region = parseInt(regionStr, 10); const row = parseInt(rowStr, 10); + const warehouseId = warehouse ? parseInt(warehouse, 10) : null; const location = await prisma.location.upsert({ where: { code: code.toUpperCase() }, - update: {}, // if exists, do nothing or update timestamps + update: { warehouse: warehouseId }, // if exists, do nothing or update timestamps create: { code: code.toUpperCase(), floor, region, sector, - row + row, + warehouse: warehouseId } });