init commit

This commit is contained in:
dmisamohin 2025-06-06 16:57:50 +03:00
parent 89f5721441
commit 80ef365a6b
13 changed files with 1568 additions and 102 deletions

113
package-lock.json generated
View File

@ -8,9 +8,15 @@
"name": "peko-avto",
"version": "0.1.0",
"dependencies": {
"@heroicons/react": "^2.2.0",
"@tanstack/react-table": "^8.21.3",
"classnames": "^2.5.1",
"next": "15.3.3",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"react-hook-form": "^7.57.0",
"tailwind-merge": "^3.3.0",
"yup": "^1.6.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@ -210,6 +216,14 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@heroicons/react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
"integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==",
"peerDependencies": {
"react": ">= 16 || ^19.0.0-rc"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -1182,6 +1196,37 @@
"tailwindcss": "4.1.8"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
@ -2139,6 +2184,11 @@
"node": ">=18"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@ -4703,6 +4753,11 @@
"react-is": "^16.13.1"
}
},
"node_modules/property-expr": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA=="
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -4751,6 +4806,21 @@
"react": "^19.1.0"
}
},
"node_modules/react-hook-form": {
"version": "7.57.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.57.0.tgz",
"integrity": "sha512-RbEks3+cbvTP84l/VXGUZ+JMrKOS8ykQCRYdm5aYsxnDquL0vspsyNhGRO7pcH6hsZqWlPOjLye7rJqdtdAmlg==",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -5337,6 +5407,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tailwind-merge": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.0.tgz",
"integrity": "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.8.tgz",
@ -5369,6 +5448,11 @@
"node": ">=18"
}
},
"node_modules/tiny-case": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q=="
},
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
@ -5409,6 +5493,11 @@
"node": ">=8.0"
}
},
"node_modules/toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="
},
"node_modules/ts-api-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
@ -5450,6 +5539,17 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-fest": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
@ -5731,6 +5831,17 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yup": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/yup/-/yup-1.6.1.tgz",
"integrity": "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==",
"dependencies": {
"property-expr": "^2.0.5",
"tiny-case": "^1.0.3",
"toposort": "^2.0.2",
"type-fest": "^2.19.0"
}
}
}
}

View File

@ -9,19 +9,25 @@
"lint": "next lint"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"@tanstack/react-table": "^8.21.3",
"classnames": "^2.5.1",
"next": "15.3.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"next": "15.3.3"
"react-hook-form": "^7.57.0",
"tailwind-merge": "^3.3.0",
"yup": "^1.6.1"
},
"devDependencies": {
"typescript": "^5",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.3.3",
"@eslint/eslintrc": "^3"
"tailwindcss": "^4",
"typescript": "^5"
}
}

137
src/app/carrier/page.tsx Normal file
View File

@ -0,0 +1,137 @@
'use client'
import { Button } from '@/components/Button';
import { Input } from '@/components/Input';
import { PageLayout } from '@/components/PageLayout';
import { Table } from '@/components/Table';
import { useState } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
interface Vehicle {
id: string;
name: string;
licensePlate: string;
driver: string;
arrivalDate: string;
}
type LicensePlateProps = {
number: string;
};
const LicensePlate = ({ number }: LicensePlateProps) => {
const regionCode = number.substring(6);
return (
<div className="flex items-center border-4 border-black w-[220px] h-[50px] bg-white rounded-md overflow-hidden shadow-md">
<div className="flex items-center justify-center text-black text-3xl font-bold px-2 w-3/4">
{number.substring(0, 6)}
</div>
<div className="flex flex-col items-center justify-center w-1/4 border-l-4 border-black text-black">
<span className="text-xl font-bold">{regionCode}</span>
<div className="flex items-center gap-1">
<span className="text-xs">RUS</span>
<div className="w-5 h-[14px] border border-black box-border">
<div className="w-full h-1 bg-white"></div>
<div className="w-full h-1 bg-blue-600"></div>
<div className="w-full h-1 bg-red-600"></div>
</div>
</div>
</div>
</div>
);
};
type AddCarFormProps = Omit<Vehicle, "id">;
const ADD_CAR_INIT: AddCarFormProps = {
arrivalDate: "",
driver: "",
licensePlate: "",
name: ""
};
export default function CarrierDashboard() {
const { register, handleSubmit, watch, reset, formState: { errors } } = useForm<AddCarFormProps>({ defaultValues: ADD_CAR_INIT });
const [isAddOpen, setIsAddOpen] = useState<Boolean>(false);
const onSubmit: SubmitHandler<AddCarFormProps> = (form) => {
console.log('form', form);
};
const handleFormClose = () => {
reset(ADD_CAR_INIT);
setIsAddOpen(false);
};
const [vehicles, setVehicles] = useState<Vehicle[]>([
{
id: '1',
name: 'Volvo FH16',
licensePlate: 'А123БВ777',
driver: 'Иванов Иван Иванович',
arrivalDate: '2023-06-15',
},
{
id: '2',
name: 'MAN TGX',
licensePlate: 'О321РТ777',
driver: 'Петров Петр Петрович',
arrivalDate: '2023-06-16',
},
]);
return (
<PageLayout pageName="Личный кабинет перевозчика">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-bold text-black mb-4">Управление вашим автопарком</h2>
</div>
<div className="px-6 py-4">
{isAddOpen && (
<div className="mb-8 p-4 bg-gray-50 rounded-lg border border-gray-200">
<h2 className="text-lg font-medium text-gray-800 mb-4">Добавить новую машину</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<Input id="name" label='Название машины' type="text" placeholder='Например: Volvo FH16' {...register("name", { required: "Это обязательное поле" })} />
<Input id="licensePlate" label='Гос. номер' type="text" placeholder='Например: А777РТ777' {...register("licensePlate", { required: "Это обязательное поле" })} />
<Input id="driver" label='Водитель' type="text" placeholder='Например: Денисов В. А.' {...register("driver", { required: "Это обязательное поле" })} />
<Input id="arrivalDate" label='Дата прибытия' type="date" {...register("arrivalDate", { required: "Это обязательное поле" })} />
</div>
<div className="flex justify-end space-x-3">
<button onClick={handleFormClose} className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium rounded-md shadow-sm transition-colors duration-200">Отмена</button>
<button onClick={handleSubmit(onSubmit)} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md shadow-sm transition-colors duration-200">
Сохранить
</button>
</div>
</div>
)}
<div>
<div className='flex justify-between'>
<h2 className="text-lg font-medium text-gray-800 mb-4">Ваши машины ({vehicles.length})</h2>
<Button onClick={() => setIsAddOpen((prev) => !prev)}>+ Добавить машину</Button>
</div>
{vehicles.length === 0 ? (
<div className="text-center py-8 text-gray-500">
У вас пока нет добавленных машин
</div>
) : (
<div className="shadow">
<Table
columns={[
{ accessorKey: "name", cell: info => info.getValue(), header: () => "Автомобиль" },
{ accessorKey: "licensePlate", cell: info => <LicensePlate number={info.getValue<string>()} />, header: () => "Гос. номер" },
{ accessorKey: "driver", cell: info => info.getValue(), header: () => "Водитель" },
{ accessorKey: "arrivalDate", cell: info => info.getValue(), header: () => "Дата прибытия" },
{ accessorKey: "delete", cell: info => <Button>Удалить</Button>, header: () => "Удалить" }
]}
tableData={vehicles}
/>
</div>
)}
</div>
</div>
</PageLayout>
);
};

View File

@ -0,0 +1,378 @@
'use client'
import { Breadcrumbs, IBreadcrumbProps } from '@/components/Breadcrumbs';
import { PageLayout } from '@/components/PageLayout';
import { useState } from 'react';
const BREADCRUMBS: IBreadcrumbProps[] = [
{
label: "Главная",
link: "/",
icon: (
<svg className="w-3 h-3 mr-2.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
</svg>
),
},
{
label: "Контрагенты",
link: "/contractors",
icon: (
<svg className="w-3 h-3 text-gray-400 mx-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
),
},
{
label: "Создание",
link: "/contractors/create",
icon: (
<svg className="w-3 h-3 text-gray-400 mx-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
)
}
];
export default function CreateContractor() {
const [formData, setFormData] = useState({
name: '',
type: 'legal', // или 'individual'
inn: '',
kpp: '',
ogrn: '',
address: '',
phone: '',
email: '',
contactPerson: '',
bankDetails: {
account: '',
bank: '',
bik: '',
correspondentAccount: ''
}
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
if (name.includes('.')) {
const [parent, child] = name.split('.');
// setFormData(prev => ({
// ...prev,
// [parent]: {
// ...prev[parent as keyof typeof formData],
// [child]: value
// }
// }));
} else {
setFormData(prev => ({
...prev,
[name]: value
}));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
// Здесь будет запрос к вашему API
const response = await fetch('/api/contractors', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
if (!response.ok) {
throw new Error('Ошибка при создании контрагента');
}
const data = await response.json();
// router.push(`/contractors/${data.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Неизвестная ошибка');
} finally {
setIsLoading(false);
}
};
return (
<PageLayout pageName='Добавление контрагента'>
<Breadcrumbs breadcrumbs={BREADCRUMBS} />
<div className="bg-white shadow rounded-lg p-6">
{error && <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Основная информация */}
<div className="space-y-4">
<h2 className="text-xl font-semibold">Основная информация</h2>
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-gray-700"
>
Наименование *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 border p-2"
/>
</div>
<div>
<label
htmlFor="type"
className="block text-sm font-medium text-gray-700"
>
Тип контрагента *
</label>
<select
id="type"
name="type"
value={formData.type}
onChange={handleChange}
required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 border p-2"
>
<option value="legal">Юридическое лицо</option>
<option value="individual">Физическое лицо/ИП</option>
</select>
</div>
<div>
<label
htmlFor="inn"
className="block text-sm font-medium text-gray-700"
>
ИНН *
</label>
<input
type="text"
id="inn"
name="inn"
value={formData.inn}
onChange={handleChange}
required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 border p-2"
/>
</div>
{formData.type === "legal" && (
<div>
<label
htmlFor="kpp"
className="block text-sm font-medium text-gray-700"
>
КПП
</label>
<input
type="text"
id="kpp"
name="kpp"
value={formData.kpp}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 border p-2"
/>
</div>
)}
<div>
<label
htmlFor="ogrn"
className="block text-sm font-medium text-gray-700"
>
{formData.type === "legal" ? "ОГРН" : "ОГРНИП"}
</label>
<input
type="text"
id="ogrn"
name="ogrn"
value={formData.ogrn}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 border p-2"
/>
</div>
</div>
<div className="space-y-4">
<h2 className="text-xl font-semibold">Контактная информация</h2>
<div>
<label
htmlFor="address"
className="block text-sm font-medium text-gray-700"
>
Адрес
</label>
<input
type="text"
id="address"
name="address"
value={formData.address}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 border p-2"
/>
</div>
<div>
<label
htmlFor="phone"
className="block text-sm font-medium text-gray-700"
>
Телефон
</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 border p-2"
/>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700"
>
Email
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 border p-2"
/>
</div>
<div>
<label
htmlFor="contactPerson"
className="block text-sm font-medium text-gray-700"
>
Контактное лицо
</label>
<input
type="text"
id="contactPerson"
name="contactPerson"
value={formData.contactPerson}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 border p-2"
/>
</div>
</div>
{/* Банковские реквизиты */}
<div className="space-y-4 md:col-span-2">
<h2 className="text-xl font-semibold">Банковские реквизиты</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
htmlFor="bankDetails.account"
className="block text-sm font-medium text-gray-700"
>
Расчетный счет
</label>
<input
type="text"
id="bankDetails.account"
name="bankDetails.account"
value={formData.bankDetails.account}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 border p-2"
/>
</div>
<div>
<label
htmlFor="bankDetails.bank"
className="block text-sm font-medium text-gray-700"
>
Банк
</label>
<input
type="text"
id="bankDetails.bank"
name="bankDetails.bank"
value={formData.bankDetails.bank}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 border p-2"
/>
</div>
<div>
<label
htmlFor="bankDetails.bik"
className="block text-sm font-medium text-gray-700"
>
БИК
</label>
<input
type="text"
id="bankDetails.bik"
name="bankDetails.bik"
value={formData.bankDetails.bik}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 border p-2"
/>
</div>
<div>
<label
htmlFor="bankDetails.correspondentAccount"
className="block text-sm font-medium text-gray-700"
>
Корр. счет
</label>
<input
type="text"
id="bankDetails.correspondentAccount"
name="bankDetails.correspondentAccount"
value={formData.bankDetails.correspondentAccount}
onChange={handleChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 border p-2"
/>
</div>
</div>
</div>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
// onClick={() => router.push('/contractors')}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Отмена
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? "Сохранение..." : "Создать контрагента"}
</button>
</div>
</form>
</div>
</PageLayout>
);
}

View File

@ -0,0 +1,243 @@
'use client'
import { Breadcrumbs, IBreadcrumbProps } from '@/components/Breadcrumbs';
import { Button } from '@/components/Button';
import { PageLayout } from '@/components/PageLayout';
import Link from 'next/link';
import { useState } from 'react';
type Vehicle = {
id: number;
name: string;
licensePlate: string;
capacity: number;
selectedRoute: number | null;
};
type Counterparty = {
id: number;
name: string;
inn: string;
vehicles: Vehicle[];
expanded: boolean;
};
type Route = {
id: number;
name: string;
};
const CounterpartiesPage = () => {
const [counterparties, setCounterparties] = useState<Counterparty[]>([
{
id: 1,
name: 'ООО "Транспортные решения"',
inn: '1234567890',
vehicles: [
{
id: 101,
name: 'ГАЗель NEXT',
licensePlate: 'А123БВ777',
capacity: 1500,
selectedRoute: null,
},
{
id: 102,
name: 'Volvo FH16',
licensePlate: 'О321РТ777',
capacity: 20000,
selectedRoute: null,
},
],
expanded: false,
},
{
id: 2,
name: 'ИП Смирнов А.В.',
inn: '0987654321',
vehicles: [
{
id: 201,
name: 'Hyundai HD78',
licensePlate: 'У456КХ777',
capacity: 3500,
selectedRoute: null,
},
],
expanded: false,
},
]);
const routes: Route[] = [
{ id: 1, name: 'Москва - Санкт-Петербург' },
{ id: 2, name: 'Москва - Нижний Новгород' },
{ id: 3, name: 'Москва - Казань' },
{ id: 4, name: 'Москва - Ростов-на-Дону' },
];
const toggleCounterparty = (id: number) => {
setCounterparties(counterparties.map(cp =>
cp.id === id ? { ...cp, expanded: !cp.expanded } : cp
));
};
const handleRouteSelect = (counterpartyId: number, vehicleId: number, routeId: number | null) => {
setCounterparties(counterparties.map(cp => {
if (cp.id !== counterpartyId) return cp;
return {
...cp,
vehicles: cp.vehicles.map(v => {
if (v.id !== vehicleId) return v;
return { ...v, selectedRoute: routeId };
})
};
}));
};
const BREADCRUMBS: IBreadcrumbProps[] = [
{
label: "Главная",
link: "/",
icon: (
<svg className="w-3 h-3 mr-2.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
</svg>
),
},
{
label: "Контрагенты",
link: "/contractors",
icon: (
<svg className="w-3 h-3 text-gray-400 mx-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
),
},
];
return (
<PageLayout pageName="Управление контрагентами">
<div className="flex justify-between">
<Breadcrumbs breadcrumbs={BREADCRUMBS} />
<Link href="contractors/create">
<Button>Добавить контрагента</Button>
</Link>
</div>
<div className="py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">
Список контрагентов
</h2>
</div>
<div className="divide-y divide-gray-200">
{counterparties.map((counterparty) => (
<div
key={counterparty.id}
className="p-6 transition-colors duration-150"
>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleCounterparty(counterparty.id)}
>
<div>
<h3 className="text-lg font-medium text-gray-900">
{counterparty.name}
</h3>
<p className="text-sm text-gray-500">ИНН: {counterparty.inn}</p>
</div>
<div className="flex items-center">
<span className="text-sm text-gray-500 mr-2">
{counterparty.vehicles.length} авто
</span>
<svg
className={`w-5 h-5 text-gray-500 transform transition-transform duration-200 ${
counterparty.expanded ? "rotate-180" : ""
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
{counterparty.expanded && (
<div className="mt-4 pl-4 border-l-2 border-gray-200">
<div className="space-y-4">
{counterparty.vehicles.map((vehicle) => (
<div key={vehicle.id} className="bg-gray-50 p-4 rounded-lg">
{/* Информация об автомобиле */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<h4 className="text-sm font-medium text-gray-500">
Модель
</h4>
<p className="mt-1 text-sm text-gray-900">
{vehicle.name}
</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500">
Гос. номер
</h4>
<p className="mt-1 text-sm text-gray-900">
{vehicle.licensePlate}
</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500">
Вместимость (кг)
</h4>
<p className="mt-1 text-sm text-gray-900">
{vehicle.capacity.toLocaleString()}
</p>
</div>
</div>
<div className="mt-4">
<label
htmlFor={`route-select-${vehicle.id}`}
className="block text-sm font-medium text-gray-700 mb-1"
>
Назначить маршрут
</label>
<select
id={`route-select-${vehicle.id}`}
className="block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
value={vehicle.selectedRoute || ""}
onChange={(e) =>
handleRouteSelect(
counterparty.id,
vehicle.id,
e.target.value ? parseInt(e.target.value) : null
)
}
>
<option value="">Не выбран</option>
{routes.map((route) => (
<option key={route.id} value={route.id}>
{route.name}
</option>
))}
</select>
</div>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
</PageLayout>
);
};
export default CounterpartiesPage;

View File

@ -0,0 +1,42 @@
import { PageLayout } from '@/components/PageLayout';
export default function DriverTicket({ driverName = "Иванов А.П.", gateNumber = "12", queueNumber = "5" }) {
return (
<PageLayout pageName='Талон водителя'>
<div className="w-full max-w-xs bg-white rounded-lg overflow-hidden shadow-lg border-t-4 border-gray-800">
<div className="bg-gray-800 text-white py-3 px-4 text-center font-bold text-lg tracking-wider">
ТАЛОН ВОДИТЕЛЯ
</div>
<div className="p-6">
<div className="mb-6">
<div className="flex justify-between items-center py-2 border-b border-dashed border-gray-200">
<span className="text-gray-600 font-bold text-sm">Водитель:</span>
<span className="text-gray-800 font-bold">{driverName}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-dashed border-gray-200">
<span className="text-gray-600 font-bold text-sm">Gate #:</span>
<span className="text-gray-800 font-bold">{gateNumber}</span>
</div>
</div>
{/* Queue Number */}
<div className="text-center mt-5">
<div className="text-5xl font-bold text-red-600 leading-none">{queueNumber}</div>
<div className="text-gray-600 text-sm mt-1">номер в очереди</div>
</div>
{/* Barcode */}
<div className="mt-6 text-center py-2 bg-gray-50 font-barcode text-4xl">
A{Math.random().toString(36).substring(2, 10).toUpperCase()}B
</div>
</div>
{/* Footer */}
<div className="text-xs text-center text-gray-500 py-2 bg-gray-50">
Талон действителен в течение 2 часов
</div>
</div>
</PageLayout>
);
};

View File

@ -1,103 +1,249 @@
import Image from "next/image";
'use client';
import { useState, useEffect } from 'react';
import { BuildingOfficeIcon, ChevronDownIcon, ChevronUpIcon, ClockIcon, MapPinIcon } from '@heroicons/react/24/outline';
import { XMarkIcon, CheckIcon, TruckIcon } from "@heroicons/react/24/outline";
import { PageLayout } from '@/components/PageLayout';
type EventType = {
id: number;
type: string;
driver?: string;
action?: string;
name?: string;
gate?: number;
time?: string;
queuePos?: number;
};
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
const [activeGate, setActiveGate] = useState<number | null>(null);
const [events, setEvents] = useState<EventType[]>([]);
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
// Загрузка данных (заглушка)
useEffect(() => {
const mockEvents: EventType[] = [
{ id: 1, type: 'ticket', driver: 'Иванов А.П.', gate: 12, time: '2 мин назад', queuePos: 5 },
{ id: 2, type: 'contractor', action: 'создан', name: 'ООО "Грузовик+"', time: '5 мин назад' },
{ id: 3, type: 'ticket', driver: 'Петров С.И.', gate: 8, time: '7 мин назад', queuePos: 2 }
];
setEvents(mockEvents);
}, []);
const gates = [5, 8, 12, 15]; // Номера гейтов
return (
<PageLayout pageName='Главная страница'>
<div className="flex flex-col gap-2">
{/* Основная карта */}
<NewTruckPanel />
<div className='flex gap-2'>
<div className="flex-1 relative overflow-hidden">
<div className="relative h-[80vh] bg-gradient-to-br from-blue-50 to-cyan-50 rounded-xl border border-gray-200">
{/* Гейты */}
{gates.map(gate => (
<Gate
key={gate}
number={gate}
active={activeGate === gate}
onClick={() => setActiveGate(gate)}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
))}
{/* Грузовики */}
<Truck x="20%" y="30%" driver="Иванов А.П." queuePos={5} />
<Truck x="60%" y="50%" driver="Петров С.И." queuePos={2} />
{/* Легенда */}
<div className="absolute bottom-4 left-4 bg-white/80 p-2 rounded-lg shadow-sm">
<div className="flex items-center space-x-3 text-sm">
<span className="flex items-center"><span className="w-3 h-3 bg-green-500 rounded-full mr-1"></span> Свободен</span>
<span className="flex items-center"><span className="w-3 h-3 bg-red-500 rounded-full mr-1"></span> Занят</span>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
</div>
</div>
</div>
<div className="w-96 bg-white border-l border-gray-200 overflow-y-auto">
<div className="p-6">
<h2 className="text-xl font-semibold flex items-center">
<ClockIcon className="h-5 w-5 mr-2 text-amber-500" />
Live-активность
</h2>
<div className="mt-6 space-y-4">
{events.map(event => (
<EventCard key={event.id} event={event} />
))}
</div>
{/* Статистика */}
<div className="mt-8">
<h3 className="font-medium text-gray-700 mb-3">Статистика за день</h3>
<div className="grid grid-cols-2 gap-3">
<StatCard icon={<TruckIcon className="h-5 w-5" />} label="Талонов" value="24" />
<StatCard icon={<BuildingOfficeIcon className="h-5 w-5" />} label="Контрагентов" value="3" />
</div>
</div>
</div>
</div>
</div>
</div>
</PageLayout>
);
}
function NewTruckPanel() {
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpand = () => setIsExpanded(!isExpanded);
return (
<div className="max-w-[350px] bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
<button
onClick={toggleExpand}
className="w-full hover:bg-gray-50 transition-colors"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
<div className="px-4 py-3 sm:px-6 lg:px-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0 p-2 bg-green-100 rounded-lg">
<TruckIcon className="h-6 w-6 text-green-600" />
</div>
<div>
<h3 className="text-sm font-medium text-left">Прибыл новый грузовик</h3>
<p className="text-xs text-gray-500">Нажмите, чтобы посмотреть подробнее</p>
</div>
</div>
<div className="ml-2">
{isExpanded ? (
<ChevronUpIcon className="h-5 w-5 text-gray-400" />
) : (
<ChevronDownIcon className="h-5 w-5 text-gray-400" />
)}
</div>
</div>
</div>
</button>
{/* Выдвигающаяся панель с подробной информацией */}
<div className={`transition-all duration-300 ease-in-out overflow-hidden ${isExpanded ? "max-h-96" : "max-h-0"}`}>
<div className="p-4 border-t border-gray-200 bg-gray-50">
<div className="flex items-center space-x-4 mb-4">
<div className="flex-shrink-0 p-3 bg-green-100 rounded-lg">
<TruckIcon className="h-8 w-8 text-green-600" />
</div>
<div>
<h4 className="text-md font-semibold">Грузовик #12345</h4>
<p className="text-sm text-gray-500">Статус: Прибыл на склад</p>
</div>
</div>
<div className="space-y-3 text-sm">
<div className="grid grid-cols-3 gap-2">
<div className="col-span-1 text-gray-500">Водитель:</div>
<div className="col-span-2">Иванов Иван Иванович</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="col-span-1 text-gray-500">Груз:</div>
<div className="col-span-2">Бытовая техника (20 коробок)</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="col-span-1 text-gray-500">Время прибытия:</div>
<div className="col-span-2">12:30, 15 июня 2023</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="col-span-1 text-gray-500">Место разгрузки:</div>
<div className="col-span-2">Склад 3, дверь 2B</div>
</div>
</div>
</div>
</div>
</div>
);
}
function Gate({ number, active, onClick }: { number: number, active: Boolean, onClick: () => void }) {
return (
<button
onClick={onClick}
className={`absolute ${getGatePosition(number)} w-24 h-24 rounded-lg border-4 flex items-center justify-center transition-all
${active ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-white'}`}
>
<span className="text-xl font-bold">#{number}</span>
</button>
);
}
function Truck({ x, y, driver, queuePos }: { x: string, y: string, driver: string, queuePos: number }) {
return (
<div
className="absolute cursor-pointer group"
style={{ left: x, top: y }}
>
<div className="bg-orange-500 text-white p-2 rounded-lg shadow-md transform -rotate-6 group-hover:rotate-0 transition">
<TruckIcon className="h-8 w-8" />
</div>
<div className="opacity-0 group-hover:opacity-100 transition bg-white shadow-lg rounded-lg p-2 mt-1 w-40">
<p className="font-medium truncate">{driver}</p>
<p className="text-sm">Очередь: <span className="font-bold">{queuePos}</span></p>
</div>
</div>
);
}
function EventCard({ event }: { event: any }) {
return (
<div className="p-3 bg-gray-50 rounded-lg border border-gray-200 hover:border-blue-300 transition">
<div className="flex items-start">
<div className={`p-2 rounded-lg mr-3 ${
event.type === 'ticket' ? 'bg-blue-100 text-blue-600' : 'bg-purple-100 text-purple-600'
}`}>
{event.type === 'ticket' ? (
<TruckIcon className="h-5 w-5" />
) : (
<BuildingOfficeIcon className="h-5 w-5" />
)}
</div>
<div>
<p className="font-medium">
{event.type === 'ticket'
? `Талон для ${event.driver} (гейт ${event.gate})`
: `Добавлен ${event.name}`}
</p>
<p className="text-sm text-gray-500 flex items-center mt-1">
<ClockIcon className="h-3 w-3 mr-1" />
{event.time}
</p>
</div>
</div>
</div>
);
}
function StatCard({ icon, label, value }: any) {
return (
<div className="bg-gray-50 p-3 rounded-lg border border-gray-200">
<div className="flex items-center">
<div className="p-2 bg-white rounded-lg mr-3 shadow-sm">
{icon}
</div>
<div>
<p className="text-sm text-gray-500">{label}</p>
<p className="text-xl font-bold">{value}</p>
</div>
</div>
</div>
);
}
// Хелперы
function getGatePosition(number: number) {
const positions = {
5: "top-[10%] left-[20%]",
8: "top-[10%] right-[20%]",
12: "bottom-[20%] left-[30%]",
15: "bottom-[20%] right-[30%]",
};
// @ts-ignore
return positions[number] || "";
}

View File

@ -0,0 +1,28 @@
import Link from 'next/link';
export type IBreadcrumbProps = {
label: string,
link: string,
icon?: React.ReactNode
};
type IBreadcrumbsProps = {
breadcrumbs: IBreadcrumbProps[];
};
export const Breadcrumbs = ({ breadcrumbs }: IBreadcrumbsProps) => {
return (
<nav className="flex mb-6" aria-label="Breadcrumb">
<ol className="inline-flex items-center gap-2">
{breadcrumbs.map(({ icon, label, link }) =>
<li key={label} className="inline-flex items-center">
<Link href={link} className="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600">
{icon}
{label}
</Link>
</li>
)}
</ol>
</nav>
);
};

View File

@ -0,0 +1,10 @@
import { ButtonHTMLAttributes } from 'react';
type IButtonProps = {
children: React.ReactNode;
} & ButtonHTMLAttributes<HTMLButtonElement>;
export const Button = ({ children, ...props }: IButtonProps) => {
const buttonClsx = "cursor-pointer px-4 py-2 bg-gray-800 hover:bg-gray-700 text-white font-medium rounded-md border border-gray-600 shadow-sm transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 disabled:opacity-50 disabled:cursor-not-allowed"
return <button {...props} className={buttonClsx}>{children}</button>;
};

View File

@ -0,0 +1,31 @@
import classNames from 'classnames';
import { InputHTMLAttributes } from 'react';
import { twMerge } from 'tailwind-merge';
type IInputProps = {
label?: string;
isError?: Boolean;
errorText?: string;
} & InputHTMLAttributes<HTMLInputElement>;
export const Input = ({ label, isError, errorText, className, id, ...props }: IInputProps) => {
const inputClsx = twMerge(
classNames("w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-1",
{
"border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500": isError,
"border-gray-300 focus:ring-blue-500 focus:border-blue-500": !isError,
}
),
className
);
return (
<div>
<label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
<input {...props} id={id} className={inputClsx} aria-invalid={isError ? "true" : "false"} aria-describedby={isError && errorText ? `${id}-error` : undefined} />
{isError && errorText && <p id={`${id}-error`} className="mt-2 text-sm text-red-600">{errorText}</p>}
</div>
);
};

View File

@ -0,0 +1,76 @@
import Head from 'next/head';
import { Sidebar } from '../Sidebar';
type PageLayoutProps = {
children: React.ReactNode;
pageName?: string;
}
export const PageLayout = ({ children, pageName }: PageLayoutProps) => {
return (
<div className="flex h-screen bg-gray-50">
<Head>
<title>{pageName}</title>
</Head>
<div className={`bg-gray-800 text-white transition-all duration-300 flex flex-col`}>
<div className="p-4 flex items-center justify-between border-b border-gray-700">
<h1 className="text-xl font-bold">PEKO-Авто</h1>
<span className="text-2xl"></span>
<button className="text-gray-400 hover:text-white">
</button>
</div>
<Sidebar />
<div className="p-4 border-t border-gray-700">
<div className="flex items-center">
<div className="w-8 h-8 rounded-full bg-gray-600 flex items-center justify-center">
<span className="text-sm">АД</span>
</div>
<div className="ml-3">
<p className="text-sm font-medium">Администратор</p>
<p className="text-xs text-gray-400">admin@company.com</p>
</div>
</div>
</div>
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<header className="bg-white shadow-sm z-10">
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center">
<h2 className="text-xl font-semibold text-gray-800">{pageName}</h2>
</div>
<div className="flex items-center space-x-4">
<button className="p-1 rounded-full text-gray-500 hover:text-gray-600 hover:bg-gray-100">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</button>
<div className="relative">
<button className="flex items-center space-x-2 focus:outline-none">
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white">
<span>АД</span>
</div>
<span className="hidden md:inline-block font-medium">
Администратор
</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
</div>
</div>
</header>
<main className="flex-1 overflow-y-auto p-6 bg-gray-50">
<div className="mx-auto">
<div className="bg-white shadow rounded-lg p-6">{children}</div>
</div>
</main>
</div>
</div>
);
};

View File

@ -0,0 +1,44 @@
import React from 'react';
import Link from 'next/link';
type ISidebarElemProps = {
label: string;
link: string;
icon: React.ReactNode;
};
const SIDEBAR_ELEMS: ISidebarElemProps[] = [
{
label: "Главная",
link: "/",
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>)
},
{
label: "Контрагенты",
link: "/contractors",
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
)
}
];
export const Sidebar = () => {
return (
<nav className="flex-1 overflow-y-auto">
<ul className="space-y-1 p-2">
{SIDEBAR_ELEMS.map(({ icon, label, link }) => (
<li key={label}>
<Link href={link} className="flex items-center p-2 rounded hover:bg-gray-700">
{icon}
<span className="ml-3">{label}</span>
</Link>
</li>
))}
</ul>
</nav>
)};

View File

@ -0,0 +1,214 @@
'use client'
import React from 'react';
import {
Column,
Table as TanStackTable,
ExpandedState,
useReactTable,
getCoreRowModel,
getPaginationRowModel,
getFilteredRowModel,
getExpandedRowModel,
ColumnDef,
flexRender,
RowData,
} from '@tanstack/react-table'
type TableProps<T extends RowData> = {
tableData: T[];
columns: ColumnDef<T>[];
};
export function Table<T extends RowData>({ tableData, columns }: TableProps<T>) {
const [expanded, setExpanded] = React.useState<ExpandedState>({})
const table = useReactTable<T>({
data: tableData,
columns,
state: {
expanded,
},
onExpandedChange: setExpanded,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getExpandedRowModel: getExpandedRowModel(),
debugTable: true,
});
return (
<div className="p-4 bg-white rounded-lg shadow-sm border border-gray-200">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th
key={header.id}
colSpan={header.colSpan}
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{header.isPlaceholder ? null : (
<div className="flex flex-col space-y-1">
<div className="flex items-center">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
{header.column.getCanFilter() && (
<div className="mt-1">
<Filter column={header.column} table={table} />
</div>
)}
</div>
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{table.getRowModel().rows.map(row => (
<tr key={row.id} className="hover:bg-gray-50 transition-colors">
{row.getVisibleCells().map(cell => (
<td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Пагинация */}
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2">
<button
className="px-3 py-1 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
{'<<'}
</button>
<button
className="px-3 py-1 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
{'<'}
</button>
<button
className="px-3 py-1 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
{'>'}
</button>
<button
className="px-3 py-1 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
{'>>'}
</button>
</div>
<div className="flex items-center gap-4 text-sm text-gray-700">
<span className="flex items-center gap-1">
Страница{' '}
<strong>
{table.getState().pagination.pageIndex + 1} из {table.getPageCount()}
</strong>
</span>
<span className="flex items-center gap-1">
Перейти:
<input
type="number"
min="1"
max={table.getPageCount()}
defaultValue={table.getState().pagination.pageIndex + 1}
onChange={e => {
const page = e.target.value ? Number(e.target.value) - 1 : 0
table.setPageIndex(page)
}}
className="w-16 px-2 py-1 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500"
/>
</span>
<select
value={table.getState().pagination.pageSize}
onChange={e => {
table.setPageSize(Number(e.target.value))
}}
className="px-2 py-1 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500"
>
{[10, 20, 30, 40, 50].map(pageSize => (
<option key={pageSize} value={pageSize}>
Показать {pageSize}
</option>
))}
</select>
</div>
</div>
</div>
)
}
function Filter({
column,
table,
}: {
column: Column<any, any>;
table: TanStackTable<any>;
}) {
const firstValue = table
.getPreFilteredRowModel()
.flatRows[0]?.getValue(column.id);
const columnFilterValue = column.getFilterValue();
return typeof firstValue === "number" ? (
<div className="flex space-x-2">
<input
type="number"
value={(columnFilterValue as [number, number])?.[0] ?? ""}
onChange={(e) =>
column.setFilterValue((old: [number, number]) => [
e.target.value,
old?.[1],
])
}
placeholder={`Min`}
className="w-24 border shadow rounded"
/>
<input
type="number"
value={(columnFilterValue as [number, number])?.[1] ?? ""}
onChange={(e) =>
column.setFilterValue((old: [number, number]) => [
old?.[0],
e.target.value,
])
}
placeholder={`Max`}
className="w-24 border shadow rounded"
/>
</div>
) : (
<input
type="text"
value={(columnFilterValue ?? "") as string}
onChange={(e) => column.setFilterValue(e.target.value)}
placeholder={`Поиск...`}
className="w-36 border shadow rounded"
/>
);
}