first commit
This commit is contained in:
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# prisma
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
/prisma/dev.db
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
export default nextConfig;
|
||||||
Generated
+3080
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "new",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.4.1",
|
||||||
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
"@yudiel/react-qr-scanner": "^2.6.0",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"framer-motion": "^12.40.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"lucide-react": "^1.17.0",
|
||||||
|
"next": "15.1.7",
|
||||||
|
"postcss": "^8.5.2",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwindcss": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"prisma": "^6.4.1"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "node prisma/seed.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
datasource db {
|
||||||
|
provider = "mysql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
USER
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
username String @unique
|
||||||
|
password String
|
||||||
|
name String?
|
||||||
|
mobile String?
|
||||||
|
role Role @default(USER)
|
||||||
|
orgId Int?
|
||||||
|
countings Counting[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Location {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
code String @unique
|
||||||
|
floor String
|
||||||
|
region Int
|
||||||
|
sector String
|
||||||
|
row Int
|
||||||
|
countings Counting[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model Counting {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
product_id Int
|
||||||
|
product_name String
|
||||||
|
warehouse Int
|
||||||
|
|
||||||
|
shelfCode String?
|
||||||
|
location Location? @relation(fields: [shelfCode], references: [code])
|
||||||
|
|
||||||
|
old_count Int
|
||||||
|
new_count Int
|
||||||
|
user_id Int
|
||||||
|
user User @relation(fields: [user_id], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const persons = [
|
||||||
|
{ name: 'ایمان کمالی', number: '09911999852', org: 0, role: 'ADMIN' },
|
||||||
|
{ name: 'طباطبایی', number: '09176852349', org: 1, role: 'USER' },
|
||||||
|
{ name: 'نکویی', number: '09174234065', org: 2, role: 'USER' },
|
||||||
|
{ name: 'عبادی', number: '09170493652', org: 3, role: 'USER' },
|
||||||
|
{ name: 'ایزدی', number: '09173260279', org: 4, role: 'USER' },
|
||||||
|
{ name: 'اسماعیل بیگ', number: '09173268033', org: 5, role: 'USER' },
|
||||||
|
{ name: 'مدیریت', number: '09177154176', org: 6, role: 'ADMIN' },
|
||||||
|
{ name: 'طاهری', number: '09382217731', org: 7, role: 'USER' },
|
||||||
|
{ name: 'نعمتی', number: '09172556207', org: 8, role: 'USER' },
|
||||||
|
{ name: 'ملکی', number: '09335585635', org: 9, role: 'USER' },
|
||||||
|
{ name: 'حیدری', number: '09173268028', org: 10, role: 'USER' },
|
||||||
|
{ name: 'آقارضا', number: '09385512778', org: 11, role: 'USER' },
|
||||||
|
{ name: 'حبیبی', number: '09338907776', org: 12, role: 'USER' },
|
||||||
|
{ name: 'قائدی', number: '09171088733', org: 13, role: 'USER' },
|
||||||
|
{ name: 'رجبی', number: '09056011806', org: 14, role: 'USER' }
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`Start seeding ...`);
|
||||||
|
for (const p of persons) {
|
||||||
|
const hashedPassword = await bcrypt.hash('123456', 10);
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { username: p.number },
|
||||||
|
update: { role: p.role },
|
||||||
|
create: {
|
||||||
|
username: p.number,
|
||||||
|
password: hashedPassword,
|
||||||
|
name: p.name,
|
||||||
|
mobile: p.number,
|
||||||
|
role: p.role,
|
||||||
|
orgId: p.org
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Created user with id: ${user.id} - ${user.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.location.upsert({
|
||||||
|
where: { code: 'C2F2' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
code: 'C2F2',
|
||||||
|
floor: 'C',
|
||||||
|
region: 2,
|
||||||
|
sector: 'F',
|
||||||
|
row: 2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Seeding finished.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
})
|
||||||
|
.catch(async (e) => {
|
||||||
|
console.error(e);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user