first commit

This commit is contained in:
2026-06-11 09:04:28 +03:30
commit e4dce4491c
27 changed files with 4422 additions and 0 deletions
+179
View File
@@ -0,0 +1,179 @@
'use client';
import { useState, useEffect } from 'react';
import Header from '@/components/Header';
import dynamic from 'next/dynamic';
const Scanner = dynamic(() => import('@yudiel/react-qr-scanner').then(mod => mod.Scanner), { ssr: false });
export default function AdminLocations() {
const [locations, setLocations] = useState([]);
const [newCode, setNewCode] = useState('');
const [loading, setLoading] = useState(false);
const [cameraEnabled, setCameraEnabled] = useState(false);
const [camError, setCamError] = useState('');
const [activeFilter, setActiveFilter] = useState('all');
useEffect(() => {
fetchLocations();
}, []);
const fetchLocations = async () => {
try {
const res = await fetch('/api/locations');
if (res.ok) {
setLocations(await res.json());
}
} catch (e) {
console.error(e);
}
};
const handleAddLocation = async (codeValue) => {
const targetCode = codeValue || newCode;
if (!targetCode) return;
setLoading(true);
try {
const res = await fetch('/api/locations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: targetCode })
});
const data = await res.json();
if (res.ok) {
alert('قفسه با موفقیت ثبت شد!');
setNewCode('');
fetchLocations();
} else {
alert(data.error || 'خطا در ثبت قفسه');
}
} catch (e) {
alert('خطای شبکه');
} finally {
setLoading(false);
setCameraEnabled(false);
}
};
const handleScan = (detectedCodes) => {
if (detectedCodes && detectedCodes.length > 0) {
const scannedValue = detectedCodes[0].rawValue;
handleAddLocation(scannedValue);
}
};
const handleError = (error) => {
console.error(error);
const msg = error?.message || error?.name || '';
if (msg.includes('Requested device not found') || msg.includes('NotFoundError') || msg.includes('device not found')) {
setCamError('هیچ دوربینی روی این دستگاه یافت نشد. لطفاً از لپ‌تاپ یا موبایل استفاده کنید.');
} else {
setCamError(msg || 'خطا در دسترسی به دوربین. آیا از HTTPS یا localhost استفاده میکنید؟');
}
};
// استخراج طبقات یکتا برای ساخت دکمه‌های فیلتر
const floors = [...new Set(locations.map(loc => loc.floor))].sort();
// اعمال فیلتر روی لیست قفسه‌ها
const filteredLocations = activeFilter === 'all'
? locations
: locations.filter(loc => loc.floor === activeFilter);
return (
<div className="w-full min-h-screen bg-gray-50 flex flex-col pb-20">
<Header title="مدیریت قفسه ها (ادمین)" showBack={true} />
<div className="p-4 flex flex-col gap-6 items-center">
<div className="w-full bg-white p-4 rounded shadow border border-gray-200">
<h2 className="font-bold mb-2">ثبت قفسه جدید</h2>
<p className="text-xs text-gray-500 mb-4">
فرمت استاندارد شامل حروف و اعداد است.
مثال: C2F2 (طبقه C، منطقه 2، قطاع F، ردیف 2)
</p>
<div className="flex flex-col gap-3">
<input
type="text"
dir="ltr"
value={newCode}
onChange={(e) => setNewCode(e.target.value)}
placeholder="مثال: C2F2"
className="w-full border p-2 rounded text-center uppercase font-bold"
/>
<button
onClick={() => handleAddLocation(newCode)}
disabled={loading}
className="bg-purple-600 text-white font-bold py-2 rounded"
>
ثبت دستی
</button>
</div>
<div className="mt-4 border-t pt-4">
{cameraEnabled ? (
<div className="w-full aspect-video relative flex flex-col items-center justify-center bg-gray-100 rounded">
{camError ? (
<div className="text-red-500 text-xs p-4 text-center">{camError}</div>
) : (
<Scanner onScan={handleScan} onError={handleError} />
)}
<button
onClick={() => { setCameraEnabled(false); setCamError(''); }}
className="absolute bottom-2 right-2 bg-red-500 text-white text-xs px-2 py-1 rounded z-10"
>
بستن دوربین
</button>
</div>
) : (
<button
onClick={() => setCameraEnabled(true)}
className="w-full bg-blue-500 text-white font-bold py-2 rounded flex justify-center items-center gap-2"
>
اسکن بارکد قفسه
</button>
)}
</div>
</div>
<div className="w-full bg-white p-4 rounded shadow border border-gray-200">
<h2 className="font-bold mb-4">قفسه های ثبت شده ({filteredLocations.length})</h2>
{/* فیلترهای تگ (اسکرول افقی) */}
{floors.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-2 mb-3 scrollbar-hide" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
<button
onClick={() => setActiveFilter('all')}
className={`px-4 py-1.5 rounded-full text-xs font-bold whitespace-nowrap transition-colors border ${activeFilter === 'all' ? 'bg-purple-600 text-white border-purple-600' : 'bg-gray-50 text-gray-600 border-gray-200'}`}
>
همه طبقات
</button>
{floors.map(floor => (
<button
key={floor}
onClick={() => setActiveFilter(floor)}
className={`px-4 py-1.5 rounded-full text-xs font-bold whitespace-nowrap transition-colors border ${activeFilter === floor ? 'bg-purple-600 text-white border-purple-600' : 'bg-gray-50 text-gray-600 border-gray-200'}`}
>
طبقه {floor}
</button>
))}
</div>
)}
<div className="flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-1">
{filteredLocations.map((loc) => (
<div key={loc.id} className="flex justify-between items-center bg-gray-50 p-2 rounded border border-gray-100 text-sm">
<span className="font-bold text-lg text-purple-700">{loc.code}</span>
<span className="text-gray-500 text-xs">طبقه {loc.floor} | منطقه {loc.region} | قطاع {loc.sector} | ردیف {loc.row}</span>
</div>
))}
{filteredLocations.length === 0 && <span className="text-center text-sm text-gray-400 py-4">موردی یافت نشد.</span>}
</div>
</div>
</div>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import prisma from '@/lib/prisma';
import { NextResponse } from 'next/server';
export async function GET() {
try {
// Get all countings grouped by floor using the relation
const countings = await prisma.counting.findMany({
include: {
location: true
}
});
// Calculate stats per floor
const stats = countings.reduce((acc, curr) => {
const floor = curr.location?.floor || 'بدون قفسه';
if (!acc[floor]) {
acc[floor] = { floor, totalCountings: 0, items: [] };
}
acc[floor].totalCountings += 1;
acc[floor].items.push(curr);
return acc;
}, {});
return NextResponse.json(Object.values(stats));
} catch (error) {
console.error(error);
return NextResponse.json({ error: 'خطا در دریافت آمار' }, { status: 500 });
}
}
+34
View File
@@ -0,0 +1,34 @@
import prisma from '@/lib/prisma';
import bcrypt from 'bcrypt';
import { signToken } from '@/lib/auth';
export async function POST(req) {
try {
const { username, password } = await req.json();
if (!username || !password) {
return Response.json({ error: 'نام کاربری و رمز عبور الزامی است.' }, { status: 400 });
}
const user = await prisma.user.findUnique({
where: { username }
});
if (!user) {
return Response.json({ error: 'کاربر یافت نشد.' }, { status: 404 });
}
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return Response.json({ error: 'رمز عبور اشتباه است.' }, { status: 401 });
}
const token = signToken({ id: user.id, username: user.username, name: user.name, orgId: user.orgId, role: user.role });
return Response.json({ message: 'با موفقیت وارد شدید', token, user: { id: user.id, name: user.name, orgId: user.orgId, role: user.role } });
} catch (error) {
console.error(error);
return Response.json({ error: 'خطای سرور رخ داد.' }, { status: 500 });
}
}
+61
View File
@@ -0,0 +1,61 @@
import prisma from '@/lib/prisma';
import { NextResponse } from 'next/server';
export async function POST(req) {
try {
const data = await req.json();
const count = await prisma.counting.create({
data: {
product_id: Number(data.product_id),
product_name: data.product_name,
warehouse: Number(data.warehouse),
shelfCode: data.shelfCode || null,
old_count: Number(data.old_count),
new_count: Number(data.new_count),
user_id: Number(data.user_id)
}
});
return NextResponse.json({ success: true, count });
} catch (error) {
console.error(error);
return NextResponse.json({ error: 'خطا در ثبت اطلاعات' }, { status: 500 });
}
}
export async function GET(req) {
const { searchParams } = new URL(req.url);
const productId = searchParams.get('product_id');
const warehouse = searchParams.get('warehouse');
const userId = searchParams.get('user_id');
try {
if (productId && warehouse) {
const lastCount = await prisma.counting.findFirst({
where: { product_id: Number(productId), warehouse: Number(warehouse) },
orderBy: { createdAt: 'desc' }
});
return NextResponse.json(lastCount || { message: -1 });
}
if (userId) {
const counts = await prisma.counting.findMany({
where: { user_id: Number(userId) },
orderBy: { createdAt: 'desc' },
include: { location: true }
});
return NextResponse.json(counts);
}
const allCounts = await prisma.counting.findMany({
orderBy: { createdAt: 'desc' },
take: 50,
include: { user: { select: { name: true } }, location: true }
});
return NextResponse.json(allCounts);
} catch (error) {
return NextResponse.json({ error: 'خطا در دریافت اطلاعات' }, { status: 500 });
}
}
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import axios from 'axios';
const HESABFA_API_KEY = 'NCuDX3bksHlhXWGIqTvatvme3YTplxdF';
const HESABFA_TOKEN = '4ddb2fc517f6f6fe6d4b9bdd08fa0df31a564a62e12c4353eb9533ae63447b57ca87c479beb7f02b276929c861dad779';
export async function POST(req) {
try {
const { code, type } = await req.json();
if (type === 'name') {
const res = await axios.post('https://api.hesabfa.com/v1/item/get', {
apiKey: HESABFA_API_KEY,
loginToken: HESABFA_TOKEN,
code: Number(code)
});
return NextResponse.json(res.data);
}
if (type === 'quantity') {
const res = await axios.post('https://api.hesabfa.com/v1/item/GetQuantity2', {
apiKey: HESABFA_API_KEY,
loginToken: HESABFA_TOKEN,
codes: [Number(code)]
});
return NextResponse.json(res.data);
}
return NextResponse.json({ error: 'نوع درخواست نامعتبر است' }, { status: 400 });
} catch (error) {
return NextResponse.json({ error: 'خطا در ارتباط با حسابفا' }, { status: 500 });
}
}
+48
View File
@@ -0,0 +1,48 @@
import prisma from '@/lib/prisma';
import { NextResponse } from 'next/server';
export async function GET() {
try {
const locations = await prisma.location.findMany({
orderBy: [{ floor: 'asc' }, { region: 'asc' }, { sector: 'asc' }, { row: 'asc' }]
});
return NextResponse.json(locations);
} catch (error) {
return NextResponse.json({ error: 'خطا در دریافت لیست انبارها' }, { status: 500 });
}
}
export async function POST(req) {
try {
const { code } = await req.json(); // "C2F2"
// Pattern validation (e.g., C2F2 -> 1 Char, 1 Num, 1 Char, 1 Num)
const regex = /^([A-Za-z]+)(\d+)([A-Za-z]+)(\d+)$/;
const match = code.toUpperCase().match(regex);
if (!match) {
return NextResponse.json({ error: 'فرمت کد قفسه نامعتبر است. مثال صحیح: C2F2' }, { status: 400 });
}
const [, floor, regionStr, sector, rowStr] = match;
const region = parseInt(regionStr, 10);
const row = parseInt(rowStr, 10);
const location = await prisma.location.upsert({
where: { code: code.toUpperCase() },
update: {}, // if exists, do nothing or update timestamps
create: {
code: code.toUpperCase(),
floor,
region,
sector,
row
}
});
return NextResponse.json({ success: true, location });
} catch (error) {
console.error(error);
return NextResponse.json({ error: 'خطا در ثبت قفسه' }, { status: 500 });
}
}
+180
View File
@@ -0,0 +1,180 @@
'use client';
import { useState, useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Header from '@/components/Header';
function CountingContent() {
const router = useRouter();
const searchParams = useSearchParams();
const code = searchParams.get('code');
const warehouse = searchParams.get('warehouse');
const [productName, setProductName] = useState('');
const [oldCount, setOldCount] = useState(null);
const [newCount, setNewCount] = useState('');
const [shelf, setShelf] = useState('');
const [lastCount, setLastCount] = useState(null);
const [loading, setLoading] = useState(true);
const [user, setUser] = useState(null);
useEffect(() => {
const userData = localStorage.getItem('user');
if (userData) {
setUser(JSON.parse(userData));
}
if (code && warehouse) {
fetchData();
}
}, [code, warehouse]);
const fetchData = async () => {
try {
// Fetch Product Name
const nameRes = await fetch('/api/hesabfa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, type: 'name' })
});
const nameData = await nameRes.json();
setProductName(nameData?.Result?.Name || 'نامشخص');
// Fetch Product Quantity
const qRes = await fetch('/api/hesabfa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, type: 'quantity' })
});
const qData = await qRes.json();
const productInfo = qData?.Result?.[0];
const wInfo = productInfo?.Warehouse?.find(w => w.Code === Number(warehouse));
setOldCount(wInfo?.Quantity ?? 0);
// Fetch Last Count
const lastRes = await fetch(`/api/counting?product_id=${code}&warehouse=${warehouse}`);
const lastData = await lastRes.json();
if (lastData.message !== -1) {
setLastCount(lastData);
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const handleSubmit = async (isConfirm) => {
const finalCount = isConfirm ? oldCount : newCount;
if (finalCount === '' || finalCount === null) {
alert('لطفا تعداد را وارد کنید.');
return;
}
try {
const res = await fetch('/api/counting', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
product_id: code,
product_name: productName,
warehouse,
shelfCode: shelf.toUpperCase(),
old_count: oldCount,
new_count: finalCount,
user_id: user?.id
})
});
if (res.ok) {
alert('شمارش با موفقیت ثبت شد!');
router.push('/dashboard');
} else {
alert('خطا در ثبت شمارش');
}
} catch (err) {
console.error(err);
}
};
return (
<div className="w-full min-h-screen bg-gray-50 flex flex-col">
<Header title="ثبت شمارش" showBack={true} />
<div className="p-4 flex flex-col gap-4">
{loading ? (
<div className="text-center p-10 animate-pulse">در حال دریافت اطلاعات...</div>
) : (
<>
<div className="bg-white p-4 rounded-lg shadow border border-gray-200">
<div className="flex justify-between items-center">
<div className="flex flex-col">
<span className="text-xs text-gray-500">کد: {code}</span>
<span className="font-bold">{productName}</span>
</div>
<div className="bg-gray-100 p-2 rounded flex flex-col items-center">
<span className="font-bold text-lg">{oldCount}</span>
<span className="text-xs text-gray-500">موجودی سیستم</span>
</div>
</div>
</div>
<div className="bg-white p-3 rounded-lg shadow text-sm border border-gray-200">
<span className="font-bold">آخرین شمارش: </span>
{lastCount ? (
<span>{lastCount.new_count} عدد (توسط کاربر {lastCount.user_id})</span>
) : (
<span className="text-gray-500">ندارد</span>
)}
</div>
<div className="flex flex-col bg-white p-4 rounded-lg shadow gap-3">
<label className="text-sm font-bold">اطلاعات فیزیکی</label>
<input
type="text"
dir="ltr"
placeholder="قفسه (مثال: C2F2)"
value={shelf}
onChange={(e) => setShelf(e.target.value)}
className="w-full border rounded p-2 text-center uppercase"
/>
<input
type="number"
dir="ltr"
placeholder="تعداد شمارش شده"
value={newCount}
onChange={(e) => setNewCount(e.target.value)}
className="w-full border-2 border-purple-200 focus:border-purple-500 rounded p-4 text-center text-2xl font-bold"
/>
</div>
<div className="flex gap-2 mt-2">
<button
onClick={() => handleSubmit(true)}
className="flex-1 py-3 bg-green-500 hover:bg-green-600 text-white font-bold rounded shadow"
>
تایید موجودی سیستم
</button>
</div>
<button
onClick={() => handleSubmit(false)}
className="w-full py-4 bg-purple-600 hover:bg-purple-700 text-white font-bold rounded shadow mt-2"
>
ثبت مغایرت / موجودی جدید
</button>
</>
)}
</div>
</div>
);
}
export default function CountingPage() {
return (
<Suspense fallback={<div>در حال بارگذاری...</div>}>
<CountingContent />
</Suspense>
);
}
+68
View File
@@ -0,0 +1,68 @@
'use client';
import Header from '@/components/Header';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { History, ScanLine, ListChecks } from 'lucide-react';
export default function Dashboard() {
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: { staggerChildren: 0.1 }
}
};
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0, transition: { type: 'spring', stiffness: 300, damping: 24 } }
};
return (
<div className="w-full min-h-screen bg-gray-50 flex flex-col">
<Header title="داشبورد" />
<motion.div
variants={container}
initial="hidden"
animate="show"
className="p-5 flex flex-col gap-4"
>
<div className="grid grid-cols-2 gap-4">
<motion.div variants={item}>
<Link href="/history" className="bg-white/80 backdrop-blur-sm border border-gray-100 rounded-3xl shadow-[0_4px_20px_rgb(0,0,0,0.03)] p-6 flex flex-col items-center justify-center aspect-square gap-3 hover:bg-white hover:scale-[1.02] active:scale-95 transition-all">
<div className="w-12 h-12 bg-blue-50 text-blue-500 rounded-2xl flex items-center justify-center mb-1">
<History strokeWidth={1.5} size={24} />
</div>
<span className="font-extrabold text-xs text-gray-700">تاریخچه شمارش</span>
</Link>
</motion.div>
<motion.div variants={item}>
<Link href="/scan" className="bg-white/80 backdrop-blur-sm border border-gray-100 rounded-3xl shadow-[0_4px_20px_rgb(0,0,0,0.03)] p-6 flex flex-col items-center justify-center aspect-square gap-3 hover:bg-white hover:scale-[1.02] active:scale-95 transition-all">
<div className="w-12 h-12 bg-purple-50 text-purple-600 rounded-2xl flex items-center justify-center mb-1">
<ScanLine strokeWidth={1.5} size={24} />
</div>
<span className="font-extrabold text-xs text-gray-700">اسکن کالا</span>
</Link>
</motion.div>
</div>
<motion.div variants={item}>
<Link href="/my-counts" className="bg-white/80 backdrop-blur-sm border border-gray-100 rounded-3xl shadow-[0_4px_20px_rgb(0,0,0,0.03)] p-5 flex items-center justify-between hover:bg-white hover:scale-[1.01] active:scale-[0.99] transition-all">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-green-50 text-green-600 rounded-xl flex items-center justify-center">
<ListChecks strokeWidth={1.5} size={20} />
</div>
<span className="font-extrabold text-sm text-gray-700">شمارشهای من</span>
</div>
<div className="w-8 h-8 bg-gray-50 rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-gray-400 rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" /></svg>
</div>
</Link>
</motion.div>
</motion.div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
@import "tailwindcss";
@font-face {
font-family: "IRANSans";
src: url("/fonts/iran.woff2") format("woff2"),
url("/fonts/iran.woff") format("woff");
font-weight: normal;
font-style: normal;
font-display: swap;
}
@theme {
--font-iran: "IRANSans", sans-serif;
}
body {
direction: rtl;
background-color: #f8fafc; /* Lighter modern gray */
font-family: var(--font-iran);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
scroll-behavior: smooth;
}
/* Hide scrollbar for horizontal scroll areas but keep functionality */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
+54
View File
@@ -0,0 +1,54 @@
'use client';
import { useState, useEffect } from 'react';
import Header from '@/components/Header';
export default function HistoryPage() {
const [counts, setCounts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchHistory = async () => {
try {
const res = await fetch(`/api/counting`);
if (res.ok) setCounts(await res.json());
} catch (e) {
console.error(e);
}
setLoading(false);
};
fetchHistory();
}, []);
return (
<div className="w-full min-h-screen bg-gray-50 flex flex-col pb-20">
<Header title="تاریخچه کل شمارش‌ها" showBack={true} />
<div className="p-4 flex flex-col gap-4">
{loading ? (
<div className="text-center p-10">در حال دریافت...</div>
) : counts.length === 0 ? (
<div className="text-center text-gray-500 p-10">تاریخچه خالی است.</div>
) : (
counts.map(count => (
<div key={count.id} className="bg-white p-4 rounded shadow border border-gray-200">
<div className="flex justify-between items-start">
<div className="flex flex-col">
<span className="font-bold text-sm">{count.product_name}</span>
<span className="text-xs text-gray-500 mt-1">شمارنده: {count.user?.name} | انبار: {count.warehouse}</span>
<span className="text-xs text-gray-500 mt-1">قفسه: {count.shelf || 'ثبت نشده'}</span>
</div>
<div className="flex flex-col items-center bg-green-50 p-2 rounded border border-green-200">
<span className="font-bold text-green-700">{count.new_count}</span>
<span className="text-[10px] text-green-600">موجودی ثبت شده</span>
</div>
</div>
<div className="mt-2 text-[10px] text-gray-400 text-left">
{new Date(count.createdAt).toLocaleString('fa-IR')}
</div>
</div>
))
)}
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import './globals.css'
export const metadata = {
title: 'Pardis Counting',
description: 'اپلیکیشن انبارگردانی پردیس',
}
export default function RootLayout({ children }) {
return (
<html lang="fa" dir="rtl">
<body>
<main className="w-full min-h-screen flex flex-col justify-start items-center bg-gray-100">
<div className="w-full max-w-md bg-white min-h-screen shadow-lg relative pb-16">
{children}
</div>
</main>
</body>
</html>
)
}
+58
View File
@@ -0,0 +1,58 @@
'use client';
import { useState, useEffect } from 'react';
import Header from '@/components/Header';
export default function MyCountsPage() {
const [counts, setCounts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchMyCounts = async () => {
const userData = localStorage.getItem('user');
if (userData) {
const user = JSON.parse(userData);
try {
const res = await fetch(`/api/counting?user_id=${user.id}`);
if (res.ok) setCounts(await res.json());
} catch (e) {
console.error(e);
}
}
setLoading(false);
};
fetchMyCounts();
}, []);
return (
<div className="w-full min-h-screen bg-gray-50 flex flex-col pb-20">
<Header title="شمارش های من" showBack={true} />
<div className="p-4 flex flex-col gap-4">
{loading ? (
<div className="text-center p-10">در حال دریافت...</div>
) : counts.length === 0 ? (
<div className="text-center text-gray-500 p-10">هنوز شمارشی ثبت نکردهاید.</div>
) : (
counts.map(count => (
<div key={count.id} className="bg-white p-4 rounded shadow border border-gray-200">
<div className="flex justify-between items-start">
<div className="flex flex-col">
<span className="font-bold text-sm">{count.product_name}</span>
<span className="text-xs text-gray-500 mt-1">کد: {count.product_id} | انبار: {count.warehouse}</span>
<span className="text-xs text-gray-500 mt-1">قفسه: {count.shelf || 'ثبت نشده'}</span>
</div>
<div className="flex flex-col items-center bg-gray-100 p-2 rounded">
<span className="font-bold">{count.new_count}</span>
<span className="text-[10px]">شمارش شما</span>
</div>
</div>
<div className="mt-2 text-[10px] text-gray-400 text-left">
{new Date(count.createdAt).toLocaleString('fa-IR')}
</div>
</div>
))
)}
</div>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter();
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
router.push('/dashboard');
}
}, [router]);
const handleLogin = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (res.ok) {
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
router.push('/dashboard');
} else {
setError(data.error);
}
} catch (err) {
setError('خطا در برقراری ارتباط با سرور');
} finally {
setLoading(false);
}
};
return (
<div className="w-full h-screen flex flex-col justify-center items-center bg-gradient-to-br from-gray-50 to-gray-100 p-4 relative overflow-hidden">
{/* Decorative Blur Backgrounds */}
<div className="absolute top-[-10%] left-[-10%] w-64 h-64 bg-purple-400/20 rounded-full blur-3xl"></div>
<div className="absolute bottom-[-10%] right-[-10%] w-64 h-64 bg-blue-400/20 rounded-full blur-3xl"></div>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
className="w-full max-w-sm p-8 bg-white/60 backdrop-blur-2xl rounded-3xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] border border-white/50 z-10"
>
<div className="flex flex-col items-center mb-8">
<div className="w-16 h-16 bg-gradient-to-tr from-purple-600 to-blue-500 rounded-2xl shadow-lg shadow-purple-500/30 flex items-center justify-center mb-4 text-white font-extrabold text-2xl">
P
</div>
<h1 className="text-xl font-extrabold text-gray-800 tracking-tight">پردیس رایانه</h1>
<p className="text-xs text-gray-500 mt-1 font-medium">اپلیکیشن مدیریت انبار</p>
</div>
{error && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="mb-6 p-3 bg-red-50/80 border border-red-100 text-red-600 text-xs rounded-xl text-center">
{error}
</motion.div>
)}
<form onSubmit={handleLogin} className="flex flex-col gap-5">
<div className="flex flex-col">
<label className="text-xs font-bold text-gray-600 mb-1.5 ml-1">شماره موبایل</label>
<input
type="text"
dir="ltr"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="px-4 py-3 bg-white/50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500 focus:bg-white transition-all text-sm"
placeholder="09xxxxxxxxx"
required
/>
</div>
<div className="flex flex-col">
<label className="text-xs font-bold text-gray-600 mb-1.5 ml-1">رمز عبور</label>
<input
type="password"
dir="ltr"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="px-4 py-3 bg-white/50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500 focus:bg-white transition-all text-sm"
placeholder="••••••••"
required
/>
</div>
<motion.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.98 }}
type="submit"
disabled={loading}
className="w-full py-3.5 bg-gray-900 hover:bg-black text-white text-sm font-bold rounded-xl transition-colors mt-2 shadow-lg shadow-gray-900/20 disabled:opacity-70"
>
{loading ? 'در حال ورود...' : 'ورود به سیستم'}
</motion.button>
</form>
<p className="text-[10px] text-center text-gray-400 mt-6 font-medium">رمز عبور پیشفرض: 123456</p>
</motion.div>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Header from '@/components/Header';
import dynamic from 'next/dynamic';
const Scanner = dynamic(() => import('@yudiel/react-qr-scanner').then(mod => mod.Scanner), { ssr: false });
export default function ScanPage() {
const [code, setCode] = useState('');
const [warehouse, setWarehouse] = useState('11');
const [cameraEnabled, setCameraEnabled] = useState(false);
const router = useRouter();
const handleGoToCounting = () => {
if (code) {
router.push(`/counting?code=${code}&warehouse=${warehouse}`);
}
};
const [camError, setCamError] = useState('');
const handleScan = (detectedCodes) => {
if (detectedCodes && detectedCodes.length > 0) {
const scannedValue = detectedCodes[0].rawValue;
setCode(scannedValue);
setCameraEnabled(false);
setTimeout(() => {
router.push(`/counting?code=${scannedValue}&warehouse=${warehouse}`);
}, 1500);
}
};
const handleError = (error) => {
console.error(error);
const msg = error?.message || error?.name || '';
if (msg.includes('Requested device not found') || msg.includes('NotFoundError') || msg.includes('device not found')) {
setCamError('هیچ دوربینی روی این دستگاه یافت نشد. لطفاً از لپ‌تاپ یا موبایل استفاده کنید.');
} else {
setCamError(msg || 'خطا در دسترسی به دوربین. آیا از HTTPS یا localhost استفاده میکنید؟');
}
};
return (
<div className="w-full min-h-screen bg-gray-50 flex flex-col pb-20">
<Header title="اسکن کد کالا" showBack={true} />
<div className="p-4 flex flex-col gap-6 items-center mt-4">
<div className="w-full max-w-sm aspect-video border-2 border-dashed border-gray-300 rounded-lg overflow-hidden flex flex-col items-center justify-center bg-gray-100 relative">
{cameraEnabled ? (
<div className="w-full h-full relative">
{camError ? (
<div className="text-red-500 text-xs p-4 text-center">{camError}</div>
) : (
<Scanner
onScan={handleScan}
onError={handleError}
formats={['qr_code', 'code_128', 'ean_13']}
/>
)}
<button
onClick={() => { setCameraEnabled(false); setCamError(''); }}
className="absolute bottom-2 right-2 bg-red-500 text-white text-xs px-2 py-1 rounded z-10"
>
بستن دوربین
</button>
</div>
) : (
<button
onClick={() => setCameraEnabled(true)}
className="text-blue-500 font-bold px-4 py-2"
>
فعال کردن دوربین برای اسکن
</button>
)}
</div>
<p className="text-sm text-gray-500">کد کالا را اسکن یا وارد کنید</p>
<input
type="number"
dir="ltr"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="ورود دستی کد"
className="w-full max-w-xs text-center text-3xl p-4 border rounded-lg focus:ring-2 focus:ring-purple-500 outline-none"
/>
<select
value={warehouse}
onChange={(e) => setWarehouse(e.target.value)}
className="w-full max-w-xs text-center text-lg p-3 border rounded-lg focus:ring-2 focus:ring-purple-500 outline-none"
>
<option value="11">مرکزی</option>
<option value="13">انبار فروشگاه</option>
<option value="14">انبار کارگاه شارژ</option>
<option value="15">انبار کارگاه تعمیرات</option>
</select>
<button
onClick={handleGoToCounting}
disabled={!code}
className="w-full max-w-xs py-4 bg-green-500 hover:bg-green-600 disabled:bg-gray-300 text-white font-bold text-xl rounded-lg transition-colors"
>
شمارش
</button>
</div>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { LogOut, ChevronRight, Wifi, WifiOff } from 'lucide-react';
export default function Header({ title = 'داشبورد', showBack = false }) {
const [user, setUser] = useState(null);
const [isOnline, setIsOnline] = useState(true);
const router = useRouter();
useEffect(() => {
const userData = localStorage.getItem('user');
if (userData) {
setUser(JSON.parse(userData));
} else {
router.push('/');
}
setIsOnline(navigator.onLine);
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, [router]);
const handleLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
router.push('/');
};
return (
<motion.header
initial={{ y: -50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
className="sticky top-0 z-50 w-full h-16 bg-white/70 backdrop-blur-xl border-b border-gray-200/50 flex items-center justify-between px-4 text-gray-800"
>
<div className="flex items-center gap-2">
{showBack ? (
<button onClick={() => router.back()} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<ChevronRight size={20} className="text-gray-600" />
</button>
) : (
<button onClick={handleLogout} className="p-2 hover:bg-red-50 text-red-500 rounded-full transition-colors flex items-center justify-center">
<LogOut size={18} />
</button>
)}
</div>
<div className="font-extrabold text-sm tracking-tight text-gray-800">
{title}
</div>
<div className="flex items-center gap-3">
<div className="flex flex-col items-end">
<span className="text-xs font-bold text-gray-700">{user?.name || 'کاربر'}</span>
{user?.role === 'ADMIN' && <Link href="/admin/locations" className="text-[10px] text-purple-600 font-bold hover:underline">مدیریت قفسهها</Link>}
</div>
<div className="flex items-center gap-1 bg-gray-50/50 p-1.5 rounded-full border border-gray-100">
{isOnline ? (
<Wifi size={14} className="text-green-500" />
) : (
<WifiOff size={14} className="text-red-500" />
)}
</div>
</div>
</motion.header>
);
}
+13
View File
@@ -0,0 +1,13 @@
import jwt from 'jsonwebtoken';
export function signToken(payload) {
return jwt.sign(payload, process.env.JWT_SECRET || 'secret', { expiresIn: '1d' });
}
export function verifyToken(token) {
try {
return jwt.verify(token, process.env.JWT_SECRET || 'secret');
} catch (error) {
return null;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client'
const prismaClientSingleton = () => {
return new PrismaClient()
}
const globalForPrisma = globalThis
const prisma = globalForPrisma.prisma ?? prismaClientSingleton()
export default prisma
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma