Initial commit: thbd-portal, duplicated from elmi-portal
Rebranded for Thunderbird Portal (THBD): numbering prefix EL->TH, all "ELMI"/"ЭЛМИ" UI strings genericized, ELMI's real contract templates and company legal/bank details removed (not applicable to this company), fresh VAPID keypair and encryption key, fresh single-migration schema history. Same architecture as elmi-portal: Web Push notifications, unread-chat app badge, cookie-session auth. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
This commit is contained in:
commit
579528edf7
197 files changed
+25022
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
data
|
||||||
|
*.env
|
||||||
|
!.env.example
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Copy to .env and fill in — never commit the real .env.
|
||||||
|
|
||||||
|
# Initial admin account, created on first start if it doesn't exist yet.
|
||||||
|
ADMIN_BOOTSTRAP_EMAIL=admin@thbd-portal.local
|
||||||
|
ADMIN_BOOTSTRAP_PASSWORD=
|
||||||
|
ADMIN_BOOTSTRAP_NAME=Admin
|
||||||
|
|
||||||
|
# AES-256-GCM key that encrypts the mailbox password at rest. Generate with:
|
||||||
|
# npm run generate-key
|
||||||
|
CREDENTIALS_ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# Used for email->inquiry extraction (Settings → Почта). Get one at
|
||||||
|
# https://console.anthropic.com/settings/keys
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
|
# Address autocomplete on the inquiry form (место погрузки/выгрузки/таможни).
|
||||||
|
# Without it the fields still work as plain text inputs, just no suggestions.
|
||||||
|
# Get a free key at https://dadata.ru/api/suggest/
|
||||||
|
DADATA_API_KEY=
|
||||||
|
|
||||||
|
# Web Push (browser/OS notifications, works even with the tab closed).
|
||||||
|
# Generate once with: npx web-push generate-vapid-keys
|
||||||
|
# Without these three set, push is silently disabled — the app still works,
|
||||||
|
# just without background notifications.
|
||||||
|
VAPID_PUBLIC_KEY=
|
||||||
|
VAPID_PRIVATE_KEY=
|
||||||
|
VAPID_SUBJECT=mailto:admin@top-sysops.ru
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# app data (SQLite db, WAL files)
|
||||||
|
/data/
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
|
# python3/make/g++ let npm fall back to compiling better-sqlite3/argon2 from
|
||||||
|
# source if no prebuilt binary matches this platform.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 make g++ ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
# `next build`'s page-data collection spins up several parallel workers that
|
||||||
|
# each import the db client at module-eval time. If data/db.sqlite doesn't
|
||||||
|
# exist yet, every worker races to create it from scratch, which can throw
|
||||||
|
# SQLITE_BUSY. Migrating first, as its own isolated step, avoids the race
|
||||||
|
# (lesson carried over from the sibling top-tickets project).
|
||||||
|
RUN mkdir -p data && npm run db:migrate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 8088
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "npm run db:migrate && npm run bootstrap-admin && npm start"]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Thunderbird Portal (THBD)
|
||||||
|
|
||||||
|
Единый портал THBD: запросы → ставки подрядчиков → заявки → документы →
|
||||||
|
слежение → финансы → отчёты. Дубликат `elmi-portal`, адаптированный под
|
||||||
|
другую компанию — та же архитектура и функциональность.
|
||||||
|
|
||||||
|
Стек: Next.js (App Router) + TypeScript, Drizzle ORM + better-sqlite3 (WAL),
|
||||||
|
Docker/docker-compose — тот же проверенный стек, что и в соседнем `top-tickets`.
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # заполнить ADMIN_BOOTSTRAP_PASSWORD
|
||||||
|
npm install
|
||||||
|
npm run db:migrate
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Прод (Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # заполнить ADMIN_BOOTSTRAP_PASSWORD
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Приложение слушает порт 8088 (см. `docker-compose.yml`, `PORT`/`DATA_DIR`).
|
||||||
|
|
||||||
|
## Роли
|
||||||
|
|
||||||
|
- **admin** — полный доступ, справочники, пользователи, шаблоны документов.
|
||||||
|
- **manager** — свои запросы/заявки/слежение.
|
||||||
|
- **accountant** — заявки (просмотр), финансы (к оплате/к получению), отчёты.
|
||||||
|
- **client** — только страница слежения по токену (`/t/[token]`), без входа.
|
||||||
|
|
||||||
|
## Известные ограничения MVP (см. план — Фаза 2/3)
|
||||||
|
|
||||||
|
- Шаблоны документов (`templates-seed/`) — пусто на старте (в отличие от
|
||||||
|
elmi-portal, откуда это скопировано). Загрузить .docx компании можно
|
||||||
|
в Настройки → Шаблоны.
|
||||||
|
- Нет email→запрос авто-парсинга (Claude API) и интеграции со СБИС/ЭДО.
|
||||||
|
- Известный интермиттентный флейк при сборке образа: `SQLITE_BUSY` во время
|
||||||
|
параллельного сбора данных страниц Turbopack'ом — при сбое просто повторить
|
||||||
|
`docker compose build` (1–3 раза), как и в `top-tickets`.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8088:8088"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- PORT=8088
|
||||||
|
- DATA_DIR=/data
|
||||||
|
volumes:
|
||||||
|
- data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data:
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
out: "./src/lib/db/migrations",
|
||||||
|
schema: "./src/lib/db/schema.ts",
|
||||||
|
dialect: "sqlite",
|
||||||
|
dbCredentials: {
|
||||||
|
url: path.join(dataDir, "db.sqlite"),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
globalIgnores([
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
// Not using output: "standalone" — file-tracing only copies argon2's
|
||||||
|
// native .node binaries, not its JS loader, causing a segfault in
|
||||||
|
// production (same issue hit in the sibling top-tickets/project-claude
|
||||||
|
// repos). Deploying with the full node_modules via `next start` is simple
|
||||||
|
// enough for a single-VM deployment.
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+9338
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "thbd-portal",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p ${PORT:-8088}",
|
||||||
|
"lint": "eslint",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"bootstrap-admin": "tsx scripts/bootstrap-admin.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.116.0",
|
||||||
|
"argon2": "^0.45.1",
|
||||||
|
"better-sqlite3": "^12.11.1",
|
||||||
|
"docxtemplater": "^3.66.4",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"framer-motion": "^12.4.7",
|
||||||
|
"html-to-text": "^10.0.0",
|
||||||
|
"imapflow": "^1.6.6",
|
||||||
|
"lucide-react": "^0.545.0",
|
||||||
|
"mailparser": "^3.9.15",
|
||||||
|
"next": "16.2.12",
|
||||||
|
"pdf-parse": "^2.4.5",
|
||||||
|
"pizzip": "^3.2.0",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.16",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/mailparser": "^3.4.6",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.12",
|
||||||
|
"tailwindcss": "^4.1.16",
|
||||||
|
"tsx": "^4.23.1",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "Thunderbird Portal",
|
||||||
|
"short_name": "THBD",
|
||||||
|
"description": "Единый портал THBD: запросы, заявки, слежение, финансы.",
|
||||||
|
"start_url": "/inquiries",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f8f7fb",
|
||||||
|
"theme_color": "#6d28d9",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Web Push service worker. Deliberately minimal — no offline caching, no
|
||||||
|
// asset interception. Its only job is to keep receiving push events even
|
||||||
|
// when the page's own tab is frozen or closed, and turn them into an OS
|
||||||
|
// notification.
|
||||||
|
|
||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
let payload = { title: "Thunderbird Portal", body: "", url: "/inquiries" };
|
||||||
|
try {
|
||||||
|
if (event.data) payload = { ...payload, ...event.data.json() };
|
||||||
|
} catch {
|
||||||
|
// malformed payload — fall back to the generic notification above
|
||||||
|
}
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
await self.registration.showNotification(payload.title, {
|
||||||
|
body: payload.body,
|
||||||
|
icon: "/icon-192.png",
|
||||||
|
data: { url: payload.url },
|
||||||
|
});
|
||||||
|
|
||||||
|
// badgeCount is only present on payloads that affect the unread chat
|
||||||
|
// count — other event types omit it entirely so the badge is left
|
||||||
|
// alone. Android/desktop only; iOS Safari has no Badging API (the
|
||||||
|
// calls just don't exist there, so this is a no-op, not an error).
|
||||||
|
if (typeof payload.badgeCount === "number" && "setAppBadge" in self.navigator) {
|
||||||
|
if (payload.badgeCount > 0) {
|
||||||
|
await self.navigator.setAppBadge(payload.badgeCount);
|
||||||
|
} else {
|
||||||
|
await self.navigator.clearAppBadge();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const url = event.notification.data && event.notification.data.url ? event.notification.data.url : "/inquiries";
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const clientsList = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||||
|
const existing = clientsList.find((c) => "focus" in c);
|
||||||
|
if (existing) {
|
||||||
|
await existing.navigate(url);
|
||||||
|
await existing.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await self.clients.openWindow(url);
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Creates the initial admin user from ADMIN_BOOTSTRAP_EMAIL /
|
||||||
|
* ADMIN_BOOTSTRAP_PASSWORD if no user with that email exists yet.
|
||||||
|
* Safe to run on every container start — idempotent.
|
||||||
|
*/
|
||||||
|
import { db } from "../src/lib/db/client";
|
||||||
|
import { users } from "../src/lib/db/schema";
|
||||||
|
import { hashPassword } from "../src/lib/auth/password";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const email = process.env.ADMIN_BOOTSTRAP_EMAIL;
|
||||||
|
const password = process.env.ADMIN_BOOTSTRAP_PASSWORD;
|
||||||
|
const name = process.env.ADMIN_BOOTSTRAP_NAME ?? "Admin";
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
console.log("ADMIN_BOOTSTRAP_EMAIL/PASSWORD not set — skipping admin bootstrap.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = email.toLowerCase().trim();
|
||||||
|
const existing = await db.query.users.findFirst({
|
||||||
|
where: (u, { eq }) => eq(u.email, normalizedEmail),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
console.log(`Admin user ${normalizedEmail} already exists — skipping.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
await db.insert(users).values({
|
||||||
|
email: normalizedEmail,
|
||||||
|
passwordHash,
|
||||||
|
name,
|
||||||
|
role: "admin",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Bootstrapped admin user ${normalizedEmail}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Admin bootstrap failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Generates a fresh AES-256-GCM key for CREDENTIALS_ENCRYPTION_KEY.
|
||||||
|
* Usage: npm run generate-key
|
||||||
|
*/
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
console.log(crypto.randomBytes(32).toString("base64"));
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const COLORS = ["#6d28d9", "#0d9488", "#2563eb", "#d97706", "#e11d48", "#16a34a"];
|
||||||
|
|
||||||
|
function colorForId(id: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||||
|
return COLORS[hash % COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
hasAvatar,
|
||||||
|
size = 32,
|
||||||
|
online,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
hasAvatar?: boolean;
|
||||||
|
size?: number;
|
||||||
|
online?: boolean;
|
||||||
|
}) {
|
||||||
|
const [failed, setFailed] = useState(!hasAvatar);
|
||||||
|
const initials = name
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((p) => p[0])
|
||||||
|
.slice(0, 2)
|
||||||
|
.join("")
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="relative inline-block shrink-0" style={{ width: size, height: size }}>
|
||||||
|
{!failed ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- small avatar thumbnail, no need for next/image pipeline
|
||||||
|
<img
|
||||||
|
src={`/api/users/${userId}/avatar`}
|
||||||
|
alt={name}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className="rounded-full object-cover"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="flex items-center justify-center rounded-full font-medium text-white"
|
||||||
|
style={{ width: size, height: size, background: colorForId(userId), fontSize: size * 0.4 }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{online !== undefined && (
|
||||||
|
<span
|
||||||
|
className={`absolute bottom-0 right-0 rounded-full border-2 border-surface ${online ? "bg-success" : "bg-text-faint"}`}
|
||||||
|
style={{ width: size * 0.32, height: size * 0.32 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { ArrowLeft, Users } from "lucide-react";
|
||||||
|
import { Avatar } from "../avatar";
|
||||||
|
import { ConversationList } from "./conversation-list";
|
||||||
|
import { MessageThread } from "./message-thread";
|
||||||
|
import { Composer } from "./composer";
|
||||||
|
import { ForwardModal } from "./forward-modal";
|
||||||
|
import { CreateTaskModal } from "./create-task-modal";
|
||||||
|
import { mapConversation, mapMessage } from "./mappers";
|
||||||
|
import type { AppEvent } from "@/lib/events/bus";
|
||||||
|
import type { ChatMessage, ConversationSummary, Person } from "./types";
|
||||||
|
|
||||||
|
const ONLINE_WINDOW_MS = 30_000;
|
||||||
|
const TYPING_THROTTLE_MS = 3_000;
|
||||||
|
const TYPING_EXPIRE_MS = 4_000;
|
||||||
|
|
||||||
|
function isOnline(lastSeenAt: number | null): boolean {
|
||||||
|
return lastSeenAt !== null && Date.now() - lastSeenAt < ONLINE_WINDOW_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatApp({
|
||||||
|
currentUser,
|
||||||
|
initialConversations,
|
||||||
|
people,
|
||||||
|
}: {
|
||||||
|
currentUser: { id: string; name: string };
|
||||||
|
initialConversations: ConversationSummary[];
|
||||||
|
people: Person[];
|
||||||
|
}) {
|
||||||
|
const [conversations, setConversations] = useState(initialConversations);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(initialConversations[0]?.id ?? null);
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
|
||||||
|
const [forwardMsg, setForwardMsg] = useState<ChatMessage | null>(null);
|
||||||
|
const [taskMsg, setTaskMsg] = useState<ChatMessage | null>(null);
|
||||||
|
const [typingName, setTypingName] = useState<string | null>(null);
|
||||||
|
const [, forceTick] = useState(0);
|
||||||
|
|
||||||
|
const lastTypingSentRef = useRef(0);
|
||||||
|
const typingClearTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const selectedIdRef = useRef(selectedId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedIdRef.current = selectedId;
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
|
// Reset per-conversation UI state during render when the selection changes
|
||||||
|
// ("adjusting state when a prop changes" — react.dev) instead of an effect,
|
||||||
|
// so it happens in the same commit rather than as a follow-up render.
|
||||||
|
const [renderedForId, setRenderedForId] = useState(selectedId);
|
||||||
|
if (selectedId !== renderedForId) {
|
||||||
|
setRenderedForId(selectedId);
|
||||||
|
setReplyTo(null);
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshConversations = useCallback(async () => {
|
||||||
|
const res = await fetch("/api/chat/conversations");
|
||||||
|
if (res.ok) setConversations((await res.json()).map(mapConversation));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMessages = useCallback(async (conversationId: string) => {
|
||||||
|
const res = await fetch(`/api/chat/conversations/${conversationId}/messages`);
|
||||||
|
if (res.ok) setMessages((await res.json()).map(mapMessage));
|
||||||
|
fetch(`/api/chat/conversations/${conversationId}/read`, { method: "POST" }).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const res = await fetch(`/api/chat/conversations/${selectedId}/messages`);
|
||||||
|
if (!cancelled && res.ok) setMessages((await res.json()).map(mapMessage));
|
||||||
|
fetch(`/api/chat/conversations/${selectedId}/read`, { method: "POST" }).catch(() => {});
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
|
// Periodic re-render so online dots decay from green to grey without a page reload.
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => forceTick((n) => n + 1), 10_000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const source = new EventSource("/api/events");
|
||||||
|
source.onmessage = (msg) => {
|
||||||
|
let event: AppEvent;
|
||||||
|
try {
|
||||||
|
event = JSON.parse(msg.data) as AppEvent;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "chat.message") {
|
||||||
|
refreshConversations();
|
||||||
|
if (event.conversationId === selectedIdRef.current) loadMessages(event.conversationId);
|
||||||
|
} else if (event.type === "chat.typing") {
|
||||||
|
if (event.conversationId === selectedIdRef.current && event.userId !== currentUser.id) {
|
||||||
|
setTypingName(event.userName);
|
||||||
|
if (typingClearTimer.current) clearTimeout(typingClearTimer.current);
|
||||||
|
typingClearTimer.current = setTimeout(() => setTypingName(null), TYPING_EXPIRE_MS);
|
||||||
|
}
|
||||||
|
} else if (event.type === "presence.update") {
|
||||||
|
setConversations((prev) =>
|
||||||
|
prev.map((c) => ({
|
||||||
|
...c,
|
||||||
|
participants: c.participants.map((p) =>
|
||||||
|
p.userId === event.userId ? { ...p, lastSeenAt: event.lastSeenAt } : p,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return () => source.close();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- refreshConversations/loadMessages are stable useCallback, re-subscribing per render would drop the connection
|
||||||
|
}, [currentUser.id]);
|
||||||
|
|
||||||
|
function handleTyping() {
|
||||||
|
if (!selectedId) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTypingSentRef.current < TYPING_THROTTLE_MS) return;
|
||||||
|
lastTypingSentRef.current = now;
|
||||||
|
fetch("/api/chat/typing", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ conversationId: selectedId }),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReact(messageId: string, emoji: string) {
|
||||||
|
await fetch(`/api/chat/messages/${messageId}/reactions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ emoji }),
|
||||||
|
});
|
||||||
|
if (selectedId) loadMessages(selectedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreated(conversationId: string) {
|
||||||
|
refreshConversations();
|
||||||
|
setSelectedId(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedConv = conversations.find((c) => c.id === selectedId) ?? null;
|
||||||
|
const peer = selectedConv && !selectedConv.isGroup ? selectedConv.participants.find((p) => p.userId !== currentUser.id) : null;
|
||||||
|
const headerTitle = selectedConv ? (selectedConv.isGroup ? selectedConv.name ?? "Группа" : peer?.name ?? "…") : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
// h-full instead of a hand-computed vh-140px: the parent shell
|
||||||
|
// (AdminLayout) already sizes itself off --app-vh (real visible
|
||||||
|
// viewport, tracks the keyboard) and gives <main> a bounded height, so
|
||||||
|
// this just needs to fill that rather than recompute it.
|
||||||
|
//
|
||||||
|
// On mobile, only one of ConversationList / the thread panel is shown
|
||||||
|
// at a time (grid-cols-1 used to stack both, which is most of why the
|
||||||
|
// thread only had a sliver of screen) — toggled by whether a
|
||||||
|
// conversation is selected, mirroring the list⇄thread pattern from
|
||||||
|
// any phone chat app. Both stay mounted (display:none via `hidden`,
|
||||||
|
// not conditional rendering) so switching back doesn't lose scroll
|
||||||
|
// position or in-progress typing.
|
||||||
|
<div className="grid h-full grid-cols-1 gap-4 lg:grid-cols-[300px_1fr]">
|
||||||
|
<ConversationList
|
||||||
|
conversations={conversations}
|
||||||
|
people={people}
|
||||||
|
selectedId={selectedId}
|
||||||
|
currentUserId={currentUser.id}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
onCreated={handleCreated}
|
||||||
|
className={selectedId ? "hidden lg:flex lg:flex-col" : "flex flex-col"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={`card flex-col overflow-hidden ${selectedId ? "flex" : "hidden lg:flex"}`}>
|
||||||
|
{selectedConv ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 border-b border-border p-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedId(null)}
|
||||||
|
className="-ml-1 rounded-md p-1 text-text-muted hover:bg-surface-hover lg:hidden"
|
||||||
|
aria-label="К списку чатов"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
{selectedConv.isGroup ? (
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-hover text-text-muted">
|
||||||
|
<Users size={15} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Avatar userId={peer?.userId ?? ""} name={headerTitle ?? ""} hasAvatar={peer?.hasAvatar} size={32} online={peer ? isOnline(peer.lastSeenAt) : false} />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">{headerTitle}</p>
|
||||||
|
<p className="h-4 text-xs text-text-faint">
|
||||||
|
{typingName
|
||||||
|
? `${typingName} печатает…`
|
||||||
|
: selectedConv.isGroup
|
||||||
|
? `${selectedConv.participants.length} участников`
|
||||||
|
: peer && isOnline(peer.lastSeenAt)
|
||||||
|
? "в сети"
|
||||||
|
: peer?.lastSeenAt
|
||||||
|
? `был(а) в сети недавно`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MessageThread
|
||||||
|
messages={messages}
|
||||||
|
currentUserId={currentUser.id}
|
||||||
|
onReply={setReplyTo}
|
||||||
|
onForward={setForwardMsg}
|
||||||
|
onCreateTask={setTaskMsg}
|
||||||
|
onReact={handleReact}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Composer
|
||||||
|
conversationId={selectedConv.id}
|
||||||
|
replyTo={replyTo}
|
||||||
|
onCancelReply={() => setReplyTo(null)}
|
||||||
|
onSent={() => {
|
||||||
|
if (selectedId) loadMessages(selectedId);
|
||||||
|
refreshConversations();
|
||||||
|
}}
|
||||||
|
onTyping={handleTyping}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-1 items-center justify-center text-sm text-text-faint">Выберите переписку или начните новую</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ForwardModal
|
||||||
|
message={forwardMsg}
|
||||||
|
conversations={conversations}
|
||||||
|
currentUserId={currentUser.id}
|
||||||
|
onClose={() => setForwardMsg(null)}
|
||||||
|
onForwarded={(targetConversationId) => {
|
||||||
|
refreshConversations();
|
||||||
|
if (targetConversationId === selectedIdRef.current) loadMessages(targetConversationId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateTaskModal message={taskMsg} people={people} currentUserId={currentUser.id} onClose={() => setTaskMsg(null)} onCreated={() => {}} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Paperclip, Send, Mic, Square, X } from "lucide-react";
|
||||||
|
import type { ChatMessage } from "./types";
|
||||||
|
|
||||||
|
function draftKey(conversationId: string): string {
|
||||||
|
return `chat-draft:${conversationId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Composer({
|
||||||
|
conversationId,
|
||||||
|
replyTo,
|
||||||
|
onCancelReply,
|
||||||
|
onSent,
|
||||||
|
onTyping,
|
||||||
|
}: {
|
||||||
|
conversationId: string;
|
||||||
|
replyTo: ChatMessage | null;
|
||||||
|
onCancelReply: () => void;
|
||||||
|
onSent: () => void;
|
||||||
|
onTyping: () => void;
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState(() => (typeof window === "undefined" ? "" : (localStorage.getItem(draftKey(conversationId)) ?? "")));
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [recording, setRecording] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const audioChunksRef = useRef<Blob[]>([]);
|
||||||
|
const recordStartRef = useRef(0);
|
||||||
|
|
||||||
|
// Switching conversations should restore *that* conversation's own unsent
|
||||||
|
// draft, not carry over whatever was being typed elsewhere — done during
|
||||||
|
// render ("adjusting state when a prop changes" — react.dev) so it lands
|
||||||
|
// in the same commit instead of a follow-up render.
|
||||||
|
const [draftLoadedForId, setDraftLoadedForId] = useState(conversationId);
|
||||||
|
if (conversationId !== draftLoadedForId) {
|
||||||
|
setDraftLoadedForId(conversationId);
|
||||||
|
setText(localStorage.getItem(draftKey(conversationId)) ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateText(value: string) {
|
||||||
|
setText(value);
|
||||||
|
if (value) localStorage.setItem(draftKey(conversationId), value);
|
||||||
|
else localStorage.removeItem(draftKey(conversationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(file?: File, kind?: "voice", durationSeconds?: number) {
|
||||||
|
if (!text.trim() && !file) return;
|
||||||
|
setSending(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
if (text.trim()) form.set("body", text.trim());
|
||||||
|
if (replyTo) form.set("replyToMessageId", replyTo.id);
|
||||||
|
if (file) form.set("file", file);
|
||||||
|
if (kind) form.set("kind", kind);
|
||||||
|
if (durationSeconds) form.set("durationSeconds", String(Math.round(durationSeconds)));
|
||||||
|
|
||||||
|
const res = await fetch(`/api/chat/conversations/${conversationId}/messages`, { method: "POST", body: form });
|
||||||
|
setSending(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось отправить");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateText("");
|
||||||
|
onCancelReply();
|
||||||
|
onSent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFilePick(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (file) send(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const recorder = new MediaRecorder(stream);
|
||||||
|
audioChunksRef.current = [];
|
||||||
|
recorder.ondataavailable = (e) => audioChunksRef.current.push(e.data);
|
||||||
|
recorder.onstop = () => {
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" });
|
||||||
|
const durationSeconds = (Date.now() - recordStartRef.current) / 1000;
|
||||||
|
const file = new File([blob], "voice-message.webm", { type: "audio/webm" });
|
||||||
|
send(file, "voice", durationSeconds);
|
||||||
|
};
|
||||||
|
recordStartRef.current = Date.now();
|
||||||
|
recorder.start();
|
||||||
|
mediaRecorderRef.current = recorder;
|
||||||
|
setRecording(true);
|
||||||
|
} catch {
|
||||||
|
setError("Нет доступа к микрофону");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
mediaRecorderRef.current?.stop();
|
||||||
|
setRecording(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-border p-3">
|
||||||
|
{replyTo && (
|
||||||
|
<div className="mb-2 flex items-center justify-between rounded-md border-l-2 border-accent bg-surface-hover px-2 py-1 text-xs">
|
||||||
|
<span className="truncate text-text-muted">
|
||||||
|
<span className="font-medium">{replyTo.authorName}:</span> {replyTo.body || "📎 Вложение"}
|
||||||
|
</span>
|
||||||
|
<button onClick={onCancelReply} className="shrink-0 text-text-faint hover:text-text">
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <p className="mb-2 text-xs text-danger-soft-text">{error}</p>}
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}}
|
||||||
|
className="flex items-end gap-2"
|
||||||
|
>
|
||||||
|
<input ref={fileInputRef} type="file" hidden onChange={handleFilePick} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={sending || recording}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
title="Прикрепить файл"
|
||||||
|
>
|
||||||
|
<Paperclip size={16} />
|
||||||
|
</button>
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateText(e.target.value);
|
||||||
|
onTyping();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
rows={1}
|
||||||
|
placeholder={recording ? "Запись голосового сообщения…" : "Сообщение…"}
|
||||||
|
disabled={recording}
|
||||||
|
className="max-h-32 flex-1 resize-none rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
{recording ? (
|
||||||
|
<button type="button" onClick={stopRecording} className="btn btn-primary" title="Остановить запись">
|
||||||
|
<Square size={15} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={startRecording} disabled={sending} className="btn btn-ghost" title="Голосовое сообщение">
|
||||||
|
<Mic size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="submit" disabled={sending || recording || !text.trim()} className="btn btn-primary" title="Отправить">
|
||||||
|
<Send size={16} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Plus, Users } from "lucide-react";
|
||||||
|
import { Avatar } from "../avatar";
|
||||||
|
import { formatRelativeTime } from "@/lib/format";
|
||||||
|
import { NewChatModal } from "./new-chat-modal";
|
||||||
|
import type { ConversationSummary, Person } from "./types";
|
||||||
|
|
||||||
|
const ONLINE_WINDOW_MS = 30_000;
|
||||||
|
|
||||||
|
function isOnline(lastSeenAt: number | null): boolean {
|
||||||
|
return lastSeenAt !== null && Date.now() - lastSeenAt < ONLINE_WINDOW_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationList({
|
||||||
|
conversations,
|
||||||
|
people,
|
||||||
|
selectedId,
|
||||||
|
currentUserId,
|
||||||
|
onSelect,
|
||||||
|
onCreated,
|
||||||
|
className = "flex flex-col",
|
||||||
|
}: {
|
||||||
|
conversations: ConversationSummary[];
|
||||||
|
people: Person[];
|
||||||
|
selectedId: string | null;
|
||||||
|
currentUserId: string;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onCreated: (id: string) => void;
|
||||||
|
/** Full display/layout classes, not just extra ones — lets the caller switch between "hidden lg:flex" and "flex" without two conflicting display utilities coexisting in one class list. */
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`card overflow-hidden ${className}`}>
|
||||||
|
<div className="flex items-center justify-between border-b border-border p-3">
|
||||||
|
<h2 className="text-sm font-semibold text-text-muted">Сообщения</h2>
|
||||||
|
<button onClick={() => setModalOpen(true)} className="btn btn-ghost" title="Новый чат">
|
||||||
|
<Plus size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{conversations.map((conv) => {
|
||||||
|
const peer = !conv.isGroup ? conv.participants.find((p) => p.userId !== currentUserId) : null;
|
||||||
|
const title = conv.isGroup ? conv.name ?? "Группа" : peer?.name ?? "…";
|
||||||
|
const online = peer ? isOnline(peer.lastSeenAt) : false;
|
||||||
|
const active = conv.id === selectedId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={conv.id}
|
||||||
|
onClick={() => onSelect(conv.id)}
|
||||||
|
className={`flex w-full items-center gap-3 border-b border-border px-3 py-2.5 text-left transition-colors ${
|
||||||
|
active ? "bg-accent-soft" : "hover:bg-surface-hover"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{conv.isGroup ? (
|
||||||
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-hover text-text-muted">
|
||||||
|
<Users size={15} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Avatar userId={peer?.userId ?? ""} name={title} hasAvatar={peer?.hasAvatar} size={32} online={online} />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className={`truncate text-sm ${conv.unreadCount > 0 ? "font-semibold" : "font-medium"}`}>{title}</span>
|
||||||
|
{conv.lastMessage && (
|
||||||
|
<span className="shrink-0 text-[11px] text-text-faint">{formatRelativeTime(conv.lastMessage.createdAt)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate text-xs text-text-muted">
|
||||||
|
{conv.lastMessage
|
||||||
|
? conv.lastMessage.body || (conv.lastMessage.hasAttachment ? "📎 Вложение" : "")
|
||||||
|
: "Нет сообщений"}
|
||||||
|
</span>
|
||||||
|
{conv.unreadCount > 0 && (
|
||||||
|
<span className="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-accent px-1 text-[10px] font-semibold text-white">
|
||||||
|
{conv.unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{conversations.length === 0 && <p className="p-4 text-sm text-text-faint">Пока нет переписок.</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NewChatModal open={modalOpen} onClose={() => setModalOpen(false)} people={people} onCreated={onCreated} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import type { ChatMessage, Person } from "./types";
|
||||||
|
|
||||||
|
function truncate(text: string, max: number): string {
|
||||||
|
return text.length > max ? `${text.slice(0, max)}…` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateTaskModal({
|
||||||
|
message,
|
||||||
|
people,
|
||||||
|
currentUserId,
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
message: ChatMessage | null;
|
||||||
|
people: Person[];
|
||||||
|
currentUserId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
}) {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [assigneeId, setAssigneeId] = useState(currentUserId);
|
||||||
|
const [dueAt, setDueAt] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Re-seed the form from the source message during render when it changes
|
||||||
|
// ("adjusting state when a prop changes" — react.dev), same idiom used in
|
||||||
|
// chat-app.tsx/composer.tsx for per-conversation state resets.
|
||||||
|
const [seededForId, setSeededForId] = useState<string | null>(null);
|
||||||
|
if (message && message.id !== seededForId) {
|
||||||
|
setSeededForId(message.id);
|
||||||
|
setTitle(truncate(message.body || "Вложение", 80));
|
||||||
|
setAssigneeId(currentUserId);
|
||||||
|
setDueAt("");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!message || !title.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch("/api/tasks", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title.trim(),
|
||||||
|
assigneeId,
|
||||||
|
dueAt: dueAt || undefined,
|
||||||
|
conversationId: message.conversationId,
|
||||||
|
messageId: message.id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
setError("Не удалось создать задачу");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onCreated();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{message && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
|
className="card w-full max-w-sm p-5"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold">Задача из сообщения</h2>
|
||||||
|
<button type="button" onClick={onClose} className="text-text-muted hover:text-text">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Заголовок</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Исполнитель</span>
|
||||||
|
<select
|
||||||
|
value={assigneeId}
|
||||||
|
onChange={(e) => setAssigneeId(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value={currentUserId}>Я</option>
|
||||||
|
{people.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Напомнить (дата и время)</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={dueAt}
|
||||||
|
onChange={(e) => setDueAt(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
<button type="submit" disabled={saving} className="btn btn-primary self-start">
|
||||||
|
Создать задачу
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { X, Users } from "lucide-react";
|
||||||
|
import { Avatar } from "../avatar";
|
||||||
|
import type { ChatMessage, ConversationSummary } from "./types";
|
||||||
|
|
||||||
|
export function ForwardModal({
|
||||||
|
message,
|
||||||
|
conversations,
|
||||||
|
currentUserId,
|
||||||
|
onClose,
|
||||||
|
onForwarded,
|
||||||
|
}: {
|
||||||
|
message: ChatMessage | null;
|
||||||
|
conversations: ConversationSummary[];
|
||||||
|
currentUserId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onForwarded: (targetConversationId: string) => void;
|
||||||
|
}) {
|
||||||
|
const [sendingTo, setSendingTo] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function forwardTo(targetConversationId: string) {
|
||||||
|
if (!message) return;
|
||||||
|
setSendingTo(targetConversationId);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch(`/api/chat/messages/${message.id}/forward`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ targetConversationId }),
|
||||||
|
});
|
||||||
|
setSendingTo(null);
|
||||||
|
if (!res.ok) {
|
||||||
|
setError("Не удалось переслать сообщение");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onForwarded(targetConversationId);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{message && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
|
className="card w-full max-w-sm p-5"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold">Переслать сообщение</h2>
|
||||||
|
<button type="button" onClick={onClose} className="text-text-muted hover:text-text">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 truncate rounded-md border-l-2 border-accent bg-surface-hover px-2 py-1 text-xs text-text-muted">
|
||||||
|
<span className="font-medium">{message.authorName}:</span> {message.body || "📎 Вложение"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="mb-3 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex max-h-72 flex-col gap-1 overflow-y-auto">
|
||||||
|
{conversations.map((conv) => {
|
||||||
|
const peer = !conv.isGroup ? conv.participants.find((p) => p.userId !== currentUserId) : null;
|
||||||
|
const title = conv.isGroup ? conv.name ?? "Группа" : peer?.name ?? "…";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={conv.id}
|
||||||
|
disabled={sendingTo !== null}
|
||||||
|
onClick={() => forwardTo(conv.id)}
|
||||||
|
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-surface-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{conv.isGroup ? (
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-surface-hover text-text-muted">
|
||||||
|
<Users size={13} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Avatar userId={peer?.userId ?? ""} name={title} hasAvatar={peer?.hasAvatar} size={28} />
|
||||||
|
)}
|
||||||
|
{title}
|
||||||
|
{sendingTo === conv.id && <span className="ml-auto text-xs text-text-faint">Отправка…</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{conversations.length === 0 && <p className="text-sm text-text-faint">Нет других переписок.</p>}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { ChatMessage, ConversationSummary } from "./types";
|
||||||
|
|
||||||
|
// The chat API returns Drizzle relational shapes (nested `user`/`author`
|
||||||
|
// objects, ISO date strings after JSON serialization) — these adapt that
|
||||||
|
// wire shape into the flat, timestamp-as-number types the UI works with.
|
||||||
|
|
||||||
|
interface RawUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatarPath: string | null;
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawParticipant {
|
||||||
|
userId: string;
|
||||||
|
user: RawUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawAttachment {
|
||||||
|
id: string;
|
||||||
|
kind: "file" | "image" | "voice";
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
durationSeconds: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawReaction {
|
||||||
|
userId: string;
|
||||||
|
emoji: string;
|
||||||
|
user: RawUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawMessage {
|
||||||
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
|
authorId: string;
|
||||||
|
author: RawUser;
|
||||||
|
body: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
replyTo: { id: string; body: string | null; author: RawUser } | null;
|
||||||
|
attachments: RawAttachment[];
|
||||||
|
reactions: RawReaction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawConversation {
|
||||||
|
id: string;
|
||||||
|
isGroup: boolean;
|
||||||
|
name: string | null;
|
||||||
|
unreadCount: number;
|
||||||
|
participants: RawParticipant[];
|
||||||
|
lastMessage: { id: string; body: string | null; authorId: string; createdAt: string; attachments: unknown[] } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapMessage(raw: RawMessage): ChatMessage {
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
conversationId: raw.conversationId,
|
||||||
|
authorId: raw.authorId,
|
||||||
|
authorName: raw.author?.name ?? "?",
|
||||||
|
authorHasAvatar: Boolean(raw.author?.avatarPath),
|
||||||
|
body: raw.body,
|
||||||
|
createdAt: new Date(raw.createdAt).getTime(),
|
||||||
|
replyTo: raw.replyTo ? { id: raw.replyTo.id, body: raw.replyTo.body, authorName: raw.replyTo.author?.name ?? "?" } : null,
|
||||||
|
attachments: (raw.attachments ?? []).map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
kind: a.kind,
|
||||||
|
fileName: a.fileName,
|
||||||
|
mimeType: a.mimeType,
|
||||||
|
durationSeconds: a.durationSeconds,
|
||||||
|
})),
|
||||||
|
reactions: (raw.reactions ?? []).map((r) => ({ emoji: r.emoji, userId: r.userId, userName: r.user?.name ?? "?" })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapConversation(raw: RawConversation): ConversationSummary {
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
isGroup: raw.isGroup,
|
||||||
|
name: raw.name,
|
||||||
|
unreadCount: raw.unreadCount,
|
||||||
|
participants: raw.participants.map((p) => ({
|
||||||
|
userId: p.userId,
|
||||||
|
name: p.user?.name ?? "?",
|
||||||
|
hasAvatar: Boolean(p.user?.avatarPath),
|
||||||
|
lastSeenAt: p.user?.lastSeenAt ? new Date(p.user.lastSeenAt).getTime() : null,
|
||||||
|
})),
|
||||||
|
lastMessage: raw.lastMessage
|
||||||
|
? {
|
||||||
|
id: raw.lastMessage.id,
|
||||||
|
body: raw.lastMessage.body,
|
||||||
|
authorId: raw.lastMessage.authorId,
|
||||||
|
createdAt: new Date(raw.lastMessage.createdAt).getTime(),
|
||||||
|
hasAttachment: (raw.lastMessage.attachments ?? []).length > 0,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { CornerUpLeft, Forward, ListTodo, Paperclip, Smile } from "lucide-react";
|
||||||
|
import { Avatar } from "../avatar";
|
||||||
|
import { formatClock, formatDayLabel } from "@/lib/format";
|
||||||
|
import type { ChatMessage } from "./types";
|
||||||
|
|
||||||
|
function isSameCalendarDay(aMs: number, bMs: number): boolean {
|
||||||
|
const a = new Date(aMs);
|
||||||
|
const b = new Date(bMs);
|
||||||
|
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUICK_EMOJI = ["👍", "❤️", "😂", "😮", "😢", "🙏"];
|
||||||
|
|
||||||
|
function groupReactions(reactions: ChatMessage["reactions"], currentUserId: string) {
|
||||||
|
const byEmoji = new Map<string, { count: number; mine: boolean; names: string[] }>();
|
||||||
|
for (const r of reactions) {
|
||||||
|
const entry = byEmoji.get(r.emoji) ?? { count: 0, mine: false, names: [] };
|
||||||
|
entry.count += 1;
|
||||||
|
entry.names.push(r.userName);
|
||||||
|
if (r.userId === currentUserId) entry.mine = true;
|
||||||
|
byEmoji.set(r.emoji, entry);
|
||||||
|
}
|
||||||
|
return [...byEmoji.entries()].map(([emoji, v]) => ({ emoji, ...v }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentView({ attachment }: { attachment: ChatMessage["attachments"][number] }) {
|
||||||
|
const url = `/api/chat/attachments/${attachment.id}`;
|
||||||
|
if (attachment.kind === "voice") {
|
||||||
|
return <audio controls src={url} className="h-9 max-w-[240px]" />;
|
||||||
|
}
|
||||||
|
if (attachment.kind === "image") {
|
||||||
|
return (
|
||||||
|
<a href={url} target="_blank" rel="noreferrer">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element -- user-uploaded chat image, arbitrary size, not worth next/image config */}
|
||||||
|
<img src={url} alt={attachment.fileName} className="max-h-60 max-w-xs rounded-md border border-border" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-2 rounded-md border border-border bg-surface-hover px-3 py-2 text-sm hover:border-accent"
|
||||||
|
>
|
||||||
|
<Paperclip size={14} />
|
||||||
|
{attachment.fileName}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageThread({
|
||||||
|
messages,
|
||||||
|
currentUserId,
|
||||||
|
onReply,
|
||||||
|
onForward,
|
||||||
|
onCreateTask,
|
||||||
|
onReact,
|
||||||
|
}: {
|
||||||
|
messages: ChatMessage[];
|
||||||
|
currentUserId: string;
|
||||||
|
onReply: (message: ChatMessage) => void;
|
||||||
|
onForward: (message: ChatMessage) => void;
|
||||||
|
onCreateTask: (message: ChatMessage) => void;
|
||||||
|
onReact: (messageId: string, emoji: string) => void;
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [pickerFor, setPickerFor] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ block: "end" });
|
||||||
|
}, [messages.length]);
|
||||||
|
|
||||||
|
// Attached images (see AttachmentView below) have no reserved width/
|
||||||
|
// height, so they finish loading — and grow the content — *after* the
|
||||||
|
// scroll-to-bottom above already ran, leaving a gap between the last
|
||||||
|
// bubble and whatever's below until the next message arrives. Re-anchor
|
||||||
|
// to bottom whenever the content's actual height changes while we're
|
||||||
|
// still near the bottom (not while the user has deliberately scrolled up
|
||||||
|
// to read history — a resize then shouldn't yank them back down).
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
const content = contentRef.current;
|
||||||
|
if (!container || !content) return;
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
const nearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 200;
|
||||||
|
if (nearBottom) bottomRef.current?.scrollIntoView({ block: "end" });
|
||||||
|
});
|
||||||
|
observer.observe(content);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="flex-1 overflow-y-auto p-4">
|
||||||
|
{/* min-h-full + justify-end: a short conversation sticks to the
|
||||||
|
bottom (by the composer, like every chat app), with any leftover
|
||||||
|
space above it — instead of the default top-anchored flow, which
|
||||||
|
left a large dead gap between the last bubble and the composer. */}
|
||||||
|
<div ref={contentRef} className="flex min-h-full flex-col justify-end gap-3">
|
||||||
|
{messages.map((m, i) => {
|
||||||
|
const mine = m.authorId === currentUserId;
|
||||||
|
const reactionGroups = groupReactions(m.reactions, currentUserId);
|
||||||
|
const showDayDivider = i === 0 || !isSameCalendarDay(m.createdAt, messages[i - 1].createdAt);
|
||||||
|
return (
|
||||||
|
<div key={m.id} className="flex flex-col gap-3">
|
||||||
|
{showDayDivider && (
|
||||||
|
<div className="flex items-center gap-3 py-1">
|
||||||
|
<div className="h-px flex-1 bg-border" />
|
||||||
|
<span className="shrink-0 text-xs font-medium text-text-faint">{formatDayLabel(m.createdAt)}</span>
|
||||||
|
<div className="h-px flex-1 bg-border" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`group flex gap-2 ${mine ? "flex-row-reverse" : ""}`}>
|
||||||
|
<Avatar userId={m.authorId} name={m.authorName} hasAvatar={m.authorHasAvatar} size={28} />
|
||||||
|
<div className={`flex max-w-[70%] flex-col gap-1 ${mine ? "items-end" : "items-start"}`}>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-text-faint">
|
||||||
|
{!mine && <span className="font-medium text-text-muted">{m.authorName}</span>}
|
||||||
|
<span>{formatClock(m.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{m.replyTo && (
|
||||||
|
<div className="max-w-full truncate rounded-md border-l-2 border-accent bg-surface-hover px-2 py-1 text-xs text-text-muted">
|
||||||
|
<span className="font-medium">{m.replyTo.authorName}:</span> {m.replyTo.body || "📎 Вложение"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{m.body && (
|
||||||
|
<div className={`whitespace-pre-wrap rounded-lg px-3 py-2 text-sm ${mine ? "bg-accent text-white" : "card"}`}>
|
||||||
|
{m.body}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{m.attachments.map((a) => (
|
||||||
|
<AttachmentView key={a.id} attachment={a} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{reactionGroups.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{reactionGroups.map((g) => (
|
||||||
|
<button
|
||||||
|
key={g.emoji}
|
||||||
|
onClick={() => onReact(m.id, g.emoji)}
|
||||||
|
title={g.names.join(", ")}
|
||||||
|
className={`rounded-full border px-1.5 py-0.5 text-xs ${
|
||||||
|
g.mine ? "border-accent bg-accent-soft" : "border-border bg-surface-hover"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{g.emoji} {g.count}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<button onClick={() => onReply(m)} className="rounded p-1 text-text-faint hover:bg-surface-hover hover:text-text" title="Ответить">
|
||||||
|
<CornerUpLeft size={13} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onForward(m)} className="rounded p-1 text-text-faint hover:bg-surface-hover hover:text-text" title="Переслать">
|
||||||
|
<Forward size={13} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onCreateTask(m)} className="rounded p-1 text-text-faint hover:bg-surface-hover hover:text-text" title="Создать задачу">
|
||||||
|
<ListTodo size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPickerFor(pickerFor === m.id ? null : m.id)}
|
||||||
|
className="rounded p-1 text-text-faint hover:bg-surface-hover hover:text-text"
|
||||||
|
title="Реакция"
|
||||||
|
>
|
||||||
|
<Smile size={13} />
|
||||||
|
</button>
|
||||||
|
{pickerFor === m.id && (
|
||||||
|
<div className="absolute top-full z-10 mt-1 flex gap-1 rounded-md border border-border bg-surface p-1 shadow-lg">
|
||||||
|
{QUICK_EMOJI.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
onClick={() => {
|
||||||
|
onReact(m.id, emoji);
|
||||||
|
setPickerFor(null);
|
||||||
|
}}
|
||||||
|
className="rounded px-1 text-base hover:bg-surface-hover"
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{messages.length === 0 && <p className="text-sm text-text-faint">Начните переписку.</p>}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { X, Users } from "lucide-react";
|
||||||
|
import { Avatar } from "../avatar";
|
||||||
|
import type { Person } from "./types";
|
||||||
|
|
||||||
|
export function NewChatModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
people,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
people: Person[];
|
||||||
|
onCreated: (conversationId: string) => void;
|
||||||
|
}) {
|
||||||
|
const [groupMode, setGroupMode] = useState(false);
|
||||||
|
const [groupName, setGroupName] = useState("");
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setGroupMode(false);
|
||||||
|
setGroupName("");
|
||||||
|
setSelected(new Set());
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startDirect(peerId: string) {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch("/api/chat/conversations", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ peerId }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
setError("Не удалось начать чат");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const conv = await res.json();
|
||||||
|
onCreated(conv.id);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(id: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGroup(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch("/api/chat/conversations", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: groupName, participantIds: [...selected] }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
setError("Не удалось создать группу");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const conv = await res.json();
|
||||||
|
onCreated(conv.id);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={close}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
|
className="card w-full max-w-sm p-5"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold">{groupMode ? "Новая группа" : "Новый чат"}</h2>
|
||||||
|
<button type="button" onClick={close} className="text-text-muted hover:text-text">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!groupMode ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-3 flex max-h-72 flex-col gap-1 overflow-y-auto">
|
||||||
|
{people.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => startDirect(p.id)}
|
||||||
|
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-surface-hover"
|
||||||
|
>
|
||||||
|
<Avatar userId={p.id} name={p.name} hasAvatar={p.hasAvatar} size={28} />
|
||||||
|
{p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{people.length === 0 && <p className="text-sm text-text-faint">Нет других сотрудников.</p>}
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => setGroupMode(true)} className="btn btn-ghost w-full justify-center">
|
||||||
|
<Users size={15} />
|
||||||
|
Создать группу
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={createGroup}>
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Название группы</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={groupName}
|
||||||
|
onChange={(e) => setGroupName(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="mb-3 flex max-h-56 flex-col gap-1 overflow-y-auto rounded-md border border-border p-1">
|
||||||
|
{people.map((p) => (
|
||||||
|
<label key={p.id} className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-surface-hover">
|
||||||
|
<input type="checkbox" checked={selected.has(p.id)} onChange={() => toggle(p.id)} />
|
||||||
|
<Avatar userId={p.id} name={p.name} hasAvatar={p.hasAvatar} size={24} />
|
||||||
|
{p.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{error && <p className="mb-3 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={loading || selected.size === 0} className="btn btn-primary flex-1 justify-center">
|
||||||
|
{loading ? "Создаём…" : "Создать"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setGroupMode(false)} className="btn btn-ghost">
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { listConversationsForUser, listChatablePeople } from "@/lib/chat/service";
|
||||||
|
import { ChatApp } from "./chat-app";
|
||||||
|
import type { ConversationSummary, Person } from "./types";
|
||||||
|
|
||||||
|
export default async function ChatPage() {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const [conversations, people] = await Promise.all([
|
||||||
|
listConversationsForUser(session.user.id),
|
||||||
|
listChatablePeople(session.user.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const initialConversations: ConversationSummary[] = conversations.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
isGroup: c.isGroup,
|
||||||
|
name: c.name,
|
||||||
|
unreadCount: c.unreadCount,
|
||||||
|
participants: c.participants.map((p) => ({
|
||||||
|
userId: p.userId,
|
||||||
|
name: p.user.name,
|
||||||
|
hasAvatar: Boolean(p.user.avatarPath),
|
||||||
|
lastSeenAt: p.user.lastSeenAt ? p.user.lastSeenAt.getTime() : null,
|
||||||
|
})),
|
||||||
|
lastMessage: c.lastMessage
|
||||||
|
? {
|
||||||
|
id: c.lastMessage.id,
|
||||||
|
body: c.lastMessage.body,
|
||||||
|
authorId: c.lastMessage.authorId,
|
||||||
|
createdAt: c.lastMessage.createdAt.getTime(),
|
||||||
|
hasAttachment: c.lastMessage.attachments.length > 0,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const initialPeople: Person[] = people.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
hasAvatar: Boolean(p.avatarPath),
|
||||||
|
lastSeenAt: p.lastSeenAt ? p.lastSeenAt.getTime() : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatApp
|
||||||
|
currentUser={{ id: session.user.id, name: session.user.name }}
|
||||||
|
initialConversations={initialConversations}
|
||||||
|
people={initialPeople}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export interface Person {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hasAvatar: boolean;
|
||||||
|
lastSeenAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationParticipant {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
hasAvatar: boolean;
|
||||||
|
lastSeenAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationSummary {
|
||||||
|
id: string;
|
||||||
|
isGroup: boolean;
|
||||||
|
name: string | null;
|
||||||
|
unreadCount: number;
|
||||||
|
participants: ConversationParticipant[];
|
||||||
|
lastMessage: { id: string; body: string | null; authorId: string; createdAt: number; hasAttachment: boolean } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatAttachment {
|
||||||
|
id: string;
|
||||||
|
kind: "file" | "image" | "voice";
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
durationSeconds: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatReaction {
|
||||||
|
emoji: string;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
|
authorId: string;
|
||||||
|
authorName: string;
|
||||||
|
authorHasAvatar: boolean;
|
||||||
|
body: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
replyTo: { id: string; body: string | null; authorName: string } | null;
|
||||||
|
attachments: ChatAttachment[];
|
||||||
|
reactions: ChatReaction[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { Bell } from "lucide-react";
|
||||||
|
import type { AppEvent } from "@/lib/events/bus";
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presence/typing events are too frequent for a toast — handled elsewhere (chat UI).
|
||||||
|
const MESSAGES: Partial<Record<AppEvent["type"], string>> = {
|
||||||
|
"inquiry.created": "Новый запрос",
|
||||||
|
"order.created": "Создана новая заявка",
|
||||||
|
"tracking.updated": "Обновлено слежение по заявке",
|
||||||
|
"order.event": "Новый комментарий на заявке",
|
||||||
|
"email_draft.created": "Новый запрос из письма — нужна проверка",
|
||||||
|
"chat.message": "Новое сообщение в чате",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EventToasts({ currentUserId }: { currentUserId: string }) {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const source = new EventSource("/api/events");
|
||||||
|
source.onmessage = (msg) => {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(msg.data) as AppEvent;
|
||||||
|
const text =
|
||||||
|
event.type === "task.reminder"
|
||||||
|
? event.assigneeId === currentUserId
|
||||||
|
? `Напоминание: ${event.title}`
|
||||||
|
: null
|
||||||
|
: MESSAGES[event.type];
|
||||||
|
if (!text) return;
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
setToasts((prev) => [...prev, { id, text }]);
|
||||||
|
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 8000);
|
||||||
|
} catch {
|
||||||
|
// ignore malformed events / heartbeat comments
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return () => source.close();
|
||||||
|
}, [currentUserId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||||
|
<AnimatePresence>
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<motion.div
|
||||||
|
key={t.id}
|
||||||
|
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
className="card flex items-center gap-2 px-4 py-3 text-sm shadow-lg"
|
||||||
|
>
|
||||||
|
<Bell size={15} className="text-accent" />
|
||||||
|
{t.text}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
|
||||||
|
export default async function FinanceLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
if (session.user.role === "manager") redirect("/orders");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-5 flex gap-1 border-b border-border">
|
||||||
|
<Link href="/finance/payables" className="px-3 py-2 text-sm font-medium text-text-muted hover:text-text">
|
||||||
|
К оплате
|
||||||
|
</Link>
|
||||||
|
<Link href="/finance/receivables" className="px-3 py-2 text-sm font-medium text-text-muted hover:text-text">
|
||||||
|
К получению
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function FinancePage() {
|
||||||
|
redirect("/finance/payables");
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Download } from "lucide-react";
|
||||||
|
import { listPayables } from "@/lib/finance/service";
|
||||||
|
import { PayablesTable } from "./payables-table";
|
||||||
|
|
||||||
|
export default async function PayablesPage() {
|
||||||
|
const rows = await listPayables();
|
||||||
|
const total = rows.reduce((acc, r) => acc + r.totalWithVat, 0);
|
||||||
|
const overdueTotal = rows.filter((r) => r.overdueDays > 0).reduce((acc, r) => acc + r.totalWithVat, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex justify-end">
|
||||||
|
<a href="/api/finance/payables/export" className="btn btn-ghost">
|
||||||
|
<Download size={15} />
|
||||||
|
Экспорт в Excel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-5 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div className="card p-4">
|
||||||
|
<p className="mb-1 text-xs text-text-muted">Всего к оплате</p>
|
||||||
|
<p className="text-2xl font-semibold">{total.toLocaleString("ru-RU")} ₽</p>
|
||||||
|
</div>
|
||||||
|
<div className="card p-4">
|
||||||
|
<p className="mb-1 text-xs text-text-muted">Просрочено</p>
|
||||||
|
<p className="text-2xl font-semibold text-danger">{overdueTotal.toLocaleString("ru-RU")} ₽</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PayablesTable
|
||||||
|
rows={rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
invoiceNumber: r.invoiceNumber,
|
||||||
|
paidStatus: r.paidStatus,
|
||||||
|
overdueDays: r.overdueDays,
|
||||||
|
totalWithVat: r.totalWithVat,
|
||||||
|
carrier: r.carrier,
|
||||||
|
order: { id: r.order.id, number: r.order.number },
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface PayableRow {
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string | null;
|
||||||
|
paidStatus: string;
|
||||||
|
overdueDays: number;
|
||||||
|
totalWithVat: number;
|
||||||
|
carrier: { name: string } | null;
|
||||||
|
order: { id: string; number: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAID_LABEL: Record<string, string> = { unpaid: "Не оплачен", partial: "Частично" };
|
||||||
|
|
||||||
|
export function PayablesTable({ rows }: { rows: PayableRow[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
function toggle(id: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
setSelected((prev) => (prev.size === rows.length ? new Set() : new Set(rows.map((r) => r.id))));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function payBulk() {
|
||||||
|
if (selected.size === 0) return;
|
||||||
|
setLoading(true);
|
||||||
|
await fetch("/api/finance/payables/bulk-pay", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids: [...selected] }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
setSelected(new Set());
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedTotal = rows.filter((r) => selected.has(r.id)).reduce((acc, r) => acc + r.totalWithVat, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card overflow-x-auto">
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<div className="flex items-center justify-between border-b border-border bg-accent-soft px-4 py-2 text-sm text-accent-soft-text">
|
||||||
|
<span>
|
||||||
|
Выбрано: {selected.size} на сумму {selectedTotal.toLocaleString("ru-RU")} ₽
|
||||||
|
</span>
|
||||||
|
<button onClick={payBulk} disabled={loading} className="btn btn-primary">
|
||||||
|
{loading ? "Отмечаем…" : "Отметить оплаченными"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left text-text-muted">
|
||||||
|
<th className="w-8 px-4 py-3">
|
||||||
|
<input type="checkbox" checked={rows.length > 0 && selected.size === rows.length} onChange={toggleAll} />
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Заявка</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Подрядчик</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Счёт</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-right">Сумма с НДС</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Статус</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-right">Просрочка</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.id} className="border-b border-border last:border-0">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<input type="checkbox" checked={selected.has(r.id)} onChange={() => toggle(r.id)} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link href={`/orders/${r.order.id}`} className="font-mono text-xs text-accent hover:underline">
|
||||||
|
{r.order.number}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{r.carrier?.name ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">{r.invoiceNumber || "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-medium">{r.totalWithVat.toLocaleString("ru-RU")} ₽</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">{PAID_LABEL[r.paidStatus]}</td>
|
||||||
|
<td className={`px-4 py-3 text-right ${r.overdueDays > 0 ? "font-semibold text-danger" : "text-text-faint"}`}>
|
||||||
|
{r.overdueDays > 0 ? `${r.overdueDays} дн.` : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-8 text-center text-text-faint">
|
||||||
|
Нет неоплаченных счетов.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Download } from "lucide-react";
|
||||||
|
import { listReceivables } from "@/lib/finance/service";
|
||||||
|
|
||||||
|
const PAID_LABEL: Record<string, string> = { unpaid: "Не оплачен", partial: "Частично" };
|
||||||
|
|
||||||
|
export default async function ReceivablesPage() {
|
||||||
|
const rows = await listReceivables();
|
||||||
|
const total = rows.reduce((acc, r) => acc + r.totalWithVat, 0);
|
||||||
|
const unsigned = rows.filter((r) => !r.docsSigned).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex justify-end">
|
||||||
|
<a href="/api/finance/receivables/export" className="btn btn-ghost">
|
||||||
|
<Download size={15} />
|
||||||
|
Экспорт в Excel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-5 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div className="card p-4">
|
||||||
|
<p className="mb-1 text-xs text-text-muted">Всего к получению</p>
|
||||||
|
<p className="text-2xl font-semibold">{total.toLocaleString("ru-RU")} ₽</p>
|
||||||
|
</div>
|
||||||
|
<div className="card p-4">
|
||||||
|
<p className="mb-1 text-xs text-text-muted">Без подписанных документов</p>
|
||||||
|
<p className="text-2xl font-semibold text-warning">{unsigned}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left text-text-muted">
|
||||||
|
<th className="px-4 py-3 font-medium">Заявка</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Клиент</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Счёt</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-right">Сумма с НДС</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Оплата</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Документы</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.id} className="border-b border-border last:border-0">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link href={`/orders/${r.order.id}`} className="font-mono text-xs text-accent hover:underline">
|
||||||
|
{r.order.number}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{r.order.customer?.name}</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">{r.invoiceNumber || "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-medium">{r.totalWithVat.toLocaleString("ru-RU")} ₽</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">{PAID_LABEL[r.paidStatus]}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`badge ${r.docsSigned ? "badge-done" : "badge-pending"}`}>
|
||||||
|
{r.docsSigned ? "Подписаны" : "Не подписаны"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-8 text-center text-text-faint">
|
||||||
|
Нет неоплаченных счетов.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export function AddQuoteForm({ inquiryId, carriers }: { inquiryId: string; carriers: { id: string; name: string }[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [carrierId, setCarrierId] = useState(carriers[0]?.id ?? "");
|
||||||
|
const [price, setPrice] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/inquiries/${inquiryId}/quotes`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ carrierId, price: Number(price), notes: notes || undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось добавить ставку");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrice("");
|
||||||
|
setNotes("");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (carriers.length === 0) {
|
||||||
|
return <p className="text-sm text-text-faint">Нет перевозчиков в справочнике — добавьте в Справочники → Контрагенты.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-wrap items-end gap-2">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Перевозчик</span>
|
||||||
|
<select
|
||||||
|
value={carrierId}
|
||||||
|
onChange={(e) => setCarrierId(e.target.value)}
|
||||||
|
className="rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{carriers.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Ставка, ₽</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
value={price}
|
||||||
|
onChange={(e) => setPrice(e.target.value)}
|
||||||
|
className="w-32 rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex-1 text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Примечание</span>
|
||||||
|
<input
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||||
|
{loading ? "Добавляем…" : "Добавить"}
|
||||||
|
</button>
|
||||||
|
{error && <p className="w-full text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Paperclip } from "lucide-react";
|
||||||
|
|
||||||
|
export function InquiryAttachmentUpload({ inquiryId }: { inquiryId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleFiles(fileList: FileList | null) {
|
||||||
|
if (!fileList || fileList.length === 0) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
for (const file of Array.from(fileList)) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("file", file);
|
||||||
|
const res = await fetch(`/api/inquiries/${inquiryId}/attachments`, { method: "POST", body: form });
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось загрузить файл");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="flex w-fit cursor-pointer items-center gap-1.5 rounded-md border border-border px-3 py-2 text-sm text-text-muted hover:border-accent hover:text-text">
|
||||||
|
<Paperclip size={14} />
|
||||||
|
{loading ? "Загружаем…" : "Прикрепить файл"}
|
||||||
|
<input type="file" multiple className="hidden" disabled={loading} onChange={(e) => handleFiles(e.target.files)} />
|
||||||
|
</label>
|
||||||
|
{error && <p className="mt-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export function InquiryActions({ inquiryId, status }: { inquiryId: string; status: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState<"convert" | "decline" | "answered" | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function convert() {
|
||||||
|
setLoading("convert");
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch(`/api/inquiries/${inquiryId}/convert`, { method: "POST" });
|
||||||
|
setLoading(null);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось создать заявку");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const order = await res.json();
|
||||||
|
router.push(`/orders/${order.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decline() {
|
||||||
|
setLoading("decline");
|
||||||
|
await fetch(`/api/inquiries/${inquiryId}/decline`, { method: "POST" });
|
||||||
|
setLoading(null);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleAnswered(answered: boolean) {
|
||||||
|
setLoading("answered");
|
||||||
|
await fetch(`/api/inquiries/${inquiryId}/answered`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ answered }),
|
||||||
|
});
|
||||||
|
setLoading(null);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "converted" || status === "declined") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{status === "new" ? (
|
||||||
|
<button onClick={() => toggleAnswered(true)} disabled={loading !== null} className="btn btn-ghost">
|
||||||
|
{loading === "answered" ? "…" : "Отметить отвеченным"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => toggleAnswered(false)} disabled={loading !== null} className="btn btn-ghost">
|
||||||
|
{loading === "answered" ? "…" : "Вернуть в «Новые»"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={convert} disabled={loading !== null} className="btn btn-primary">
|
||||||
|
{loading === "convert" ? "Создаём заявку…" : "Создать заявку"}
|
||||||
|
</button>
|
||||||
|
<button onClick={decline} disabled={loading !== null} className="btn btn-ghost">
|
||||||
|
{loading === "decline" ? "…" : "Отменить запрос"}
|
||||||
|
</button>
|
||||||
|
{error && <p className="text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AddressAutocomplete } from "@/components/address-autocomplete";
|
||||||
|
|
||||||
|
interface Inquiry {
|
||||||
|
id: string;
|
||||||
|
cargoDescription: string;
|
||||||
|
routeFrom: string;
|
||||||
|
routeTo: string;
|
||||||
|
customsPlace: string | null;
|
||||||
|
transportMode: "road" | "rail" | "sea" | "air" | null;
|
||||||
|
transportType: "consolidated" | "tent" | "trawl" | "backhaul" | "container" | null;
|
||||||
|
comment: string | null;
|
||||||
|
deadline: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InquiryEditForm({ inquiry }: { inquiry: Inquiry }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [cargoDescription, setCargoDescription] = useState(inquiry.cargoDescription);
|
||||||
|
const [routeFrom, setRouteFrom] = useState(inquiry.routeFrom);
|
||||||
|
const [routeTo, setRouteTo] = useState(inquiry.routeTo);
|
||||||
|
const [customsPlace, setCustomsPlace] = useState(inquiry.customsPlace ?? "");
|
||||||
|
const [transportMode, setTransportMode] = useState(inquiry.transportMode ?? "");
|
||||||
|
const [transportType, setTransportType] = useState(inquiry.transportType ?? "");
|
||||||
|
const [comment, setComment] = useState(inquiry.comment ?? "");
|
||||||
|
const [deadline, setDeadline] = useState(inquiry.deadline ?? "");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSaved(false);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/inquiries/${inquiry.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
cargoDescription,
|
||||||
|
routeFrom,
|
||||||
|
routeTo,
|
||||||
|
customsPlace: customsPlace || undefined,
|
||||||
|
transportMode: transportMode || undefined,
|
||||||
|
transportType: transportType || undefined,
|
||||||
|
comment: comment || undefined,
|
||||||
|
deadline: deadline || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось сохранить");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaved(true);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Груз</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={cargoDescription}
|
||||||
|
onChange={(e) => setCargoDescription(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mb-3 grid grid-cols-2 gap-3">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Место погрузки</span>
|
||||||
|
<AddressAutocomplete required value={routeFrom} onChange={setRouteFrom} />
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Место выгрузки</span>
|
||||||
|
<AddressAutocomplete required value={routeTo} onChange={setRouteTo} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Место таможенного оформления</span>
|
||||||
|
<AddressAutocomplete value={customsPlace} onChange={setCustomsPlace} placeholder="если требуется" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mb-3 grid grid-cols-2 gap-3">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Вид транспорта</span>
|
||||||
|
<select
|
||||||
|
value={transportMode}
|
||||||
|
onChange={(e) => setTransportMode(e.target.value as typeof transportMode)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="road">Авто</option>
|
||||||
|
<option value="rail">ЖД</option>
|
||||||
|
<option value="sea">Море</option>
|
||||||
|
<option value="air">Авиа</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Тип транспорта</span>
|
||||||
|
<select
|
||||||
|
value={transportType}
|
||||||
|
onChange={(e) => setTransportType(e.target.value as typeof transportType)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="consolidated">Сборка</option>
|
||||||
|
<option value="tent">Тент</option>
|
||||||
|
<option value="trawl">Трал</option>
|
||||||
|
<option value="backhaul">Догруз</option>
|
||||||
|
<option value="container">Контейнер</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Сроки</span>
|
||||||
|
<input
|
||||||
|
value={deadline}
|
||||||
|
onChange={(e) => setDeadline(e.target.value)}
|
||||||
|
placeholder="напр. до 15.08"
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mb-4 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Комментарии / доп. информация</span>
|
||||||
|
<textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||||
|
{loading ? "Сохраняем…" : "Сохранить"}
|
||||||
|
</button>
|
||||||
|
{saved && <span className="text-sm text-text-muted">Сохранено</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft, FileText, Download } from "lucide-react";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { getInquiry } from "@/lib/inquiries/service";
|
||||||
|
import { listCarriers } from "@/lib/counterparties/service";
|
||||||
|
import { AddQuoteForm } from "./add-quote-form";
|
||||||
|
import { InquiryActions } from "./inquiry-actions";
|
||||||
|
import { InquiryAttachmentUpload } from "./attachment-upload";
|
||||||
|
import { InquiryEditForm } from "./inquiry-edit-form";
|
||||||
|
import { QuotesList } from "./quotes-list";
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
new: "Новый",
|
||||||
|
quoted: "Отвечен",
|
||||||
|
converted: "В заявке",
|
||||||
|
declined: "Отклонён",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_LABEL: Record<string, string> = {
|
||||||
|
email: "Почта",
|
||||||
|
phone: "Телефон",
|
||||||
|
messenger: "Мессенджер",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function InquiryDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const [inquiry, carriers] = await Promise.all([getInquiry(id), listCarriers()]);
|
||||||
|
if (!inquiry) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Link href="/inquiries" className="mb-4 inline-flex items-center gap-1 text-sm text-text-muted hover:text-text">
|
||||||
|
<ArrowLeft size={14} />
|
||||||
|
Запросы
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-xl font-bold tracking-tight">{inquiry.number}</h1>
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
{inquiry.customer?.name} · менеджер {inquiry.manager?.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`badge badge-${inquiry.status === "converted" ? "closed" : inquiry.status === "declined" ? "declined" : inquiry.status === "quoted" ? "open" : "new"}`}>
|
||||||
|
{STATUS_LABEL[inquiry.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-5 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<div className="card p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-muted">Груз и маршрут</h2>
|
||||||
|
<InquiryEditForm
|
||||||
|
inquiry={{
|
||||||
|
id: inquiry.id,
|
||||||
|
cargoDescription: inquiry.cargoDescription,
|
||||||
|
routeFrom: inquiry.routeFrom,
|
||||||
|
routeTo: inquiry.routeTo,
|
||||||
|
customsPlace: inquiry.customsPlace,
|
||||||
|
transportMode: inquiry.transportMode,
|
||||||
|
transportType: inquiry.transportType,
|
||||||
|
comment: inquiry.comment,
|
||||||
|
deadline: inquiry.deadline,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="mt-3 text-xs text-text-faint">Источник: {SOURCE_LABEL[inquiry.source]}</p>
|
||||||
|
|
||||||
|
{inquiry.cargoItems.length > 0 && (
|
||||||
|
<div className="mt-4 border-t border-border pt-3">
|
||||||
|
<p className="mb-2 text-sm font-medium text-text-muted">Места</p>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs text-text-faint">
|
||||||
|
<th className="pb-1 font-medium">Кол-во</th>
|
||||||
|
<th className="pb-1 font-medium">Д×Ш×В, мм</th>
|
||||||
|
<th className="pb-1 font-medium">Вес, кг</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{inquiry.cargoItems.map((item) => (
|
||||||
|
<tr key={item.id} className="border-t border-border">
|
||||||
|
<td className="py-1">{item.quantity}</td>
|
||||||
|
<td className="py-1">
|
||||||
|
{[item.lengthMm, item.widthMm, item.heightMm].filter(Boolean).join("×") || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-1">{item.weightKg ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-muted">Действия</h2>
|
||||||
|
<InquiryActions inquiryId={inquiry.id} status={inquiry.status} />
|
||||||
|
{inquiry.status === "converted" && (
|
||||||
|
<p className="mt-2 text-sm text-text-muted">
|
||||||
|
Заявка создана:{" "}
|
||||||
|
<Link href={`/orders/${inquiry.convertedOrderId}`} className="text-accent hover:underline">
|
||||||
|
открыть
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-5 card p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-muted">Файлы</h2>
|
||||||
|
<div className="mb-3 flex flex-col gap-1.5">
|
||||||
|
{inquiry.attachments.map((a) => (
|
||||||
|
<a
|
||||||
|
key={a.id}
|
||||||
|
href={`/api/inquiry-attachments/${a.id}`}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm hover:border-accent"
|
||||||
|
>
|
||||||
|
<FileText size={14} className="shrink-0 text-text-muted" />
|
||||||
|
<span className="flex-1 truncate">{a.fileName}</span>
|
||||||
|
{a.uploadedBy && <span className="text-xs text-text-faint">{a.uploadedBy.name}</span>}
|
||||||
|
<Download size={14} className="shrink-0 text-text-muted" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
{inquiry.attachments.length === 0 && <p className="text-sm text-text-faint">Пока нет файлов.</p>}
|
||||||
|
</div>
|
||||||
|
<InquiryAttachmentUpload inquiryId={inquiry.id} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-muted">Ставки подрядчиков и клиенту</h2>
|
||||||
|
<QuotesList inquiryId={inquiry.id} quotes={inquiry.quotes} clientRate={inquiry.clientRate} />
|
||||||
|
<AddQuoteForm inquiryId={inquiry.id} carriers={carriers.map((c) => ({ id: c.id, name: c.name }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Trash2, Check, Pencil } from "lucide-react";
|
||||||
|
|
||||||
|
interface Quote {
|
||||||
|
id: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
notes: string | null;
|
||||||
|
carrier: { name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuotesList({ inquiryId, quotes, clientRate }: { inquiryId: string; quotes: Quote[]; clientRate: number | null }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||||
|
const [editingRate, setEditingRate] = useState(false);
|
||||||
|
const [rateInput, setRateInput] = useState(clientRate != null ? String(clientRate) : "");
|
||||||
|
const [savingRate, setSavingRate] = useState(false);
|
||||||
|
|
||||||
|
async function remove(quoteId: string) {
|
||||||
|
setRemovingId(quoteId);
|
||||||
|
await fetch(`/api/inquiries/${inquiryId}/quotes/${quoteId}`, { method: "DELETE" });
|
||||||
|
setRemovingId(null);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRate() {
|
||||||
|
setSavingRate(true);
|
||||||
|
await fetch(`/api/inquiries/${inquiryId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ clientRate: rateInput ? Number(rateInput) : null }),
|
||||||
|
});
|
||||||
|
setSavingRate(false);
|
||||||
|
setEditingRate(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const bestCost = quotes.length > 0 ? Math.min(...quotes.filter((q) => q.currency === "RUB").map((q) => q.price)) : null;
|
||||||
|
const margin = clientRate != null && bestCost != null ? clientRate - bestCost : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex flex-col gap-2">
|
||||||
|
{quotes.map((q) => (
|
||||||
|
<div key={q.id} className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm">
|
||||||
|
<span className="font-medium">{q.carrier?.name}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span>
|
||||||
|
{q.price.toLocaleString("ru-RU")} {q.currency}
|
||||||
|
</span>
|
||||||
|
{q.notes && <span className="text-text-muted">{q.notes}</span>}
|
||||||
|
<button
|
||||||
|
onClick={() => remove(q.id)}
|
||||||
|
disabled={removingId === q.id}
|
||||||
|
className="text-text-faint hover:text-danger"
|
||||||
|
title="Удалить ставку"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{quotes.length === 0 && <p className="text-sm text-text-faint">Пока нет ставок.</p>}
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center justify-between rounded-md border border-accent/30 bg-accent-soft px-3 py-2 text-sm">
|
||||||
|
<span className="font-medium text-accent-soft-text">Ставка клиенту, ₽</span>
|
||||||
|
{editingRate ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
autoFocus
|
||||||
|
value={rateInput}
|
||||||
|
onChange={(e) => setRateInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && saveRate()}
|
||||||
|
className="w-28 rounded-md border border-border bg-surface px-2 py-1 text-right outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<button onClick={saveRate} disabled={savingRate} className="text-accent hover:text-accent/80">
|
||||||
|
<Check size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setEditingRate(true)} className="flex items-center gap-1.5 font-semibold text-accent-soft-text hover:underline">
|
||||||
|
{clientRate != null ? `${clientRate.toLocaleString("ru-RU")} ₽` : "указать"}
|
||||||
|
<Pencil size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{margin != null && (
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm">
|
||||||
|
<span className="font-medium">Маржа (по лучшей ставке)</span>
|
||||||
|
<span className={`font-semibold ${margin >= 0 ? "text-success" : "text-danger"}`}>{margin.toLocaleString("ru-RU")} ₽</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
interface Quote {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InquiryItem {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
status: "new" | "quoted" | "converted" | "declined";
|
||||||
|
cargoDescription: string;
|
||||||
|
routeFrom: string;
|
||||||
|
routeTo: string;
|
||||||
|
createdAt: Date;
|
||||||
|
firstRespondedAt: Date | null;
|
||||||
|
customer: { name: string } | null;
|
||||||
|
manager: { name: string } | null;
|
||||||
|
quotes: Quote[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Column {
|
||||||
|
status: InquiryItem["status"];
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateInputValue(date: Date): string {
|
||||||
|
const d = new Date(date);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InquiriesBoard({ inquiries, columns }: { inquiries: InquiryItem[]; columns: Column[] }) {
|
||||||
|
const [createdFrom, setCreatedFrom] = useState("");
|
||||||
|
const [createdTo, setCreatedTo] = useState("");
|
||||||
|
const [answeredFrom, setAnsweredFrom] = useState("");
|
||||||
|
const [answeredTo, setAnsweredTo] = useState("");
|
||||||
|
|
||||||
|
const filtered = inquiries.filter((inq) => {
|
||||||
|
const createdMs = new Date(inq.createdAt).getTime();
|
||||||
|
if (createdFrom && createdMs < new Date(createdFrom).getTime()) return false;
|
||||||
|
if (createdTo && createdMs > new Date(`${createdTo}T23:59:59`).getTime()) return false;
|
||||||
|
if (answeredFrom || answeredTo) {
|
||||||
|
if (!inq.firstRespondedAt) return false;
|
||||||
|
const answeredMs = new Date(inq.firstRespondedAt).getTime();
|
||||||
|
if (answeredFrom && answeredMs < new Date(answeredFrom).getTime()) return false;
|
||||||
|
if (answeredTo && answeredMs > new Date(`${answeredTo}T23:59:59`).getTime()) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtersActive = Boolean(createdFrom || createdTo || answeredFrom || answeredTo);
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
setCreatedFrom("");
|
||||||
|
setCreatedTo("");
|
||||||
|
setAnsweredFrom("");
|
||||||
|
setAnsweredTo("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="card mb-4 flex flex-wrap items-end gap-3 p-3">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Дата внесения с</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={createdFrom}
|
||||||
|
onChange={(e) => setCreatedFrom(e.target.value)}
|
||||||
|
className="rounded-md border border-border bg-surface px-2 py-1.5 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">по</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={createdTo}
|
||||||
|
onChange={(e) => setCreatedTo(e.target.value)}
|
||||||
|
className="rounded-md border border-border bg-surface px-2 py-1.5 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Дата ответа с</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={answeredFrom}
|
||||||
|
onChange={(e) => setAnsweredFrom(e.target.value)}
|
||||||
|
className="rounded-md border border-border bg-surface px-2 py-1.5 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">по</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={answeredTo}
|
||||||
|
onChange={(e) => setAnsweredTo(e.target.value)}
|
||||||
|
className="rounded-md border border-border bg-surface px-2 py-1.5 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{filtersActive && (
|
||||||
|
<button onClick={resetFilters} className="btn btn-ghost">
|
||||||
|
<X size={14} />
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto text-xs text-text-muted">
|
||||||
|
Показано {filtered.length} из {inquiries.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{columns.map((col) => {
|
||||||
|
const items = filtered.filter((i) => i.status === col.status);
|
||||||
|
return (
|
||||||
|
<div key={col.status}>
|
||||||
|
<h2 className="mb-2 flex items-center gap-2 text-sm font-semibold text-text-muted">
|
||||||
|
{col.label}
|
||||||
|
<span className="rounded-full bg-surface-hover px-1.5 py-0.5 text-xs">{items.length}</span>
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{items.map((inq) => (
|
||||||
|
<Link key={inq.id} href={`/inquiries/${inq.id}`} className="card block p-3 hover:border-accent">
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<span className="font-mono text-xs text-text-faint">{inq.number}</span>
|
||||||
|
{inq.quotes.length > 0 && (
|
||||||
|
<span className="rounded-full bg-accent-soft px-1.5 py-0.5 text-[10px] text-accent-soft-text">
|
||||||
|
{inq.quotes.length} ставок
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-1 truncate text-sm font-medium">{inq.cargoDescription}</div>
|
||||||
|
<div className="truncate text-xs text-text-muted">
|
||||||
|
{inq.customer?.name} · {inq.routeFrom} → {inq.routeTo}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center justify-between text-xs text-text-faint">
|
||||||
|
<span>{inq.manager?.name}</span>
|
||||||
|
<span>{toDateInputValue(inq.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && <p className="text-xs text-text-faint">Пусто</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { SearchableSelect } from "@/components/searchable-select";
|
||||||
|
import { AddressAutocomplete } from "@/components/address-autocomplete";
|
||||||
|
|
||||||
|
interface Draft {
|
||||||
|
id: string;
|
||||||
|
fromEmail: string;
|
||||||
|
fromName: string | null;
|
||||||
|
subject: string;
|
||||||
|
rawBody: string;
|
||||||
|
extractedCargoDescription: string | null;
|
||||||
|
extractedLengthMm: number | null;
|
||||||
|
extractedWidthMm: number | null;
|
||||||
|
extractedHeightMm: number | null;
|
||||||
|
extractedWeightKg: number | null;
|
||||||
|
extractedRouteFrom: string | null;
|
||||||
|
extractedRouteTo: string | null;
|
||||||
|
extractedDeadline: string | null;
|
||||||
|
confidence: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIDENCE_LABEL: Record<string, string> = { high: "Высокая", medium: "Средняя", low: "Низкая" };
|
||||||
|
|
||||||
|
export function DraftReviewCard({
|
||||||
|
draft,
|
||||||
|
customers,
|
||||||
|
managers,
|
||||||
|
currentUserId,
|
||||||
|
}: {
|
||||||
|
draft: Draft;
|
||||||
|
customers: { id: string; name: string }[];
|
||||||
|
managers: { id: string; name: string }[];
|
||||||
|
currentUserId: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [customerId, setCustomerId] = useState(customers[0]?.id ?? "");
|
||||||
|
const [managerId, setManagerId] = useState(currentUserId);
|
||||||
|
const [cargoDescription, setCargoDescription] = useState(draft.extractedCargoDescription ?? "");
|
||||||
|
const [routeFrom, setRouteFrom] = useState(draft.extractedRouteFrom ?? "");
|
||||||
|
const [routeTo, setRouteTo] = useState(draft.extractedRouteTo ?? "");
|
||||||
|
const [customsPlace, setCustomsPlace] = useState("");
|
||||||
|
const [deadline, setDeadline] = useState(draft.extractedDeadline ?? "");
|
||||||
|
const [loading, setLoading] = useState<"accept" | "reject" | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function accept() {
|
||||||
|
setLoading("accept");
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch(`/api/email-drafts/${draft.id}/accept`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
customerId,
|
||||||
|
managerId,
|
||||||
|
cargoDescription,
|
||||||
|
routeFrom,
|
||||||
|
routeTo,
|
||||||
|
customsPlace: customsPlace || undefined,
|
||||||
|
deadline: deadline || undefined,
|
||||||
|
lengthMm: draft.extractedLengthMm ?? undefined,
|
||||||
|
widthMm: draft.extractedWidthMm ?? undefined,
|
||||||
|
heightMm: draft.extractedHeightMm ?? undefined,
|
||||||
|
weightKg: draft.extractedWeightKg ?? undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setLoading(null);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось создать запрос");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reject() {
|
||||||
|
setLoading("reject");
|
||||||
|
await fetch(`/api/email-drafts/${draft.id}/reject`, { method: "POST" });
|
||||||
|
setLoading(null);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customers.length === 0 || managers.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<p className="text-sm text-text-faint">
|
||||||
|
Нужны хотя бы один клиент и один менеджер в справочниках, чтобы подтвердить черновик.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{draft.subject}</p>
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
{draft.fromName} <{draft.fromEmail}>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="badge badge-new">Уверенность: {CONFIDENCE_LABEL[draft.confidence]}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="mb-3 text-xs text-text-faint">
|
||||||
|
<summary className="cursor-pointer">Исходное письмо</summary>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap">{draft.rawBody}</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div className="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Клиент</span>
|
||||||
|
<SearchableSelect
|
||||||
|
value={customerId}
|
||||||
|
onChange={setCustomerId}
|
||||||
|
options={customers.map((c) => ({ id: c.id, label: c.name }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Менеджер</span>
|
||||||
|
<select
|
||||||
|
value={managerId}
|
||||||
|
onChange={(e) => setManagerId(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{managers.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm sm:col-span-2">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Груз</span>
|
||||||
|
<input
|
||||||
|
value={cargoDescription}
|
||||||
|
onChange={(e) => setCargoDescription(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Место погрузки</span>
|
||||||
|
<AddressAutocomplete value={routeFrom} onChange={setRouteFrom} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Место выгрузки</span>
|
||||||
|
<AddressAutocomplete value={routeTo} onChange={setRouteTo} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm sm:col-span-2">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Место таможенного оформления</span>
|
||||||
|
<AddressAutocomplete value={customsPlace} onChange={setCustomsPlace} placeholder="если требуется" />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm sm:col-span-2">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Сроки</span>
|
||||||
|
<input
|
||||||
|
value={deadline}
|
||||||
|
onChange={(e) => setDeadline(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="mb-3 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={accept} disabled={loading !== null || !cargoDescription || !routeFrom || !routeTo} className="btn btn-primary">
|
||||||
|
{loading === "accept" ? "Создаём…" : "Создать запрос"}
|
||||||
|
</button>
|
||||||
|
<button onClick={reject} disabled={loading !== null} className="btn btn-ghost">
|
||||||
|
{loading === "reject" ? "…" : "Отклонить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { listPendingDrafts } from "@/lib/inquiries/drafts";
|
||||||
|
import { listClients } from "@/lib/counterparties/service";
|
||||||
|
import { listManagers } from "@/lib/users/service";
|
||||||
|
import { DraftReviewCard } from "./draft-review-card";
|
||||||
|
|
||||||
|
export default async function DraftsPage() {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const [drafts, clients, managers] = await Promise.all([listPendingDrafts(), listClients(), listManagers()]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Link href="/inquiries" className="mb-4 inline-flex items-center gap-1 text-sm text-text-muted hover:text-text">
|
||||||
|
<ArrowLeft size={14} />
|
||||||
|
Запросы
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Черновики из писем</h1>
|
||||||
|
<p className="mb-5 text-sm text-text-muted">Извлечено автоматически из входящей почты — проверьте перед созданием запроса.</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{drafts.map((d) => (
|
||||||
|
<DraftReviewCard
|
||||||
|
key={d.id}
|
||||||
|
draft={d}
|
||||||
|
customers={clients.map((c) => ({ id: c.id, name: c.name }))}
|
||||||
|
managers={managers.map((m) => ({ id: m.id, name: m.name }))}
|
||||||
|
currentUserId={session.user.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{drafts.length === 0 && <p className="text-sm text-text-faint">Нет черновиков, ожидающих проверки.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { X, Plus, Trash2, Paperclip, FileSpreadsheet } from "lucide-react";
|
||||||
|
import { SearchableSelect } from "@/components/searchable-select";
|
||||||
|
import { AddressAutocomplete } from "@/components/address-autocomplete";
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CargoItem {
|
||||||
|
quantity: string;
|
||||||
|
lengthMm: string;
|
||||||
|
widthMm: string;
|
||||||
|
heightMm: string;
|
||||||
|
weightKg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_ITEM: CargoItem = { quantity: "1", lengthMm: "", widthMm: "", heightMm: "", weightKg: "" };
|
||||||
|
|
||||||
|
export function NewInquiryModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
customers,
|
||||||
|
managers,
|
||||||
|
currentUserId,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
customers: Option[];
|
||||||
|
managers: Option[];
|
||||||
|
currentUserId: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [customerId, setCustomerId] = useState(customers[0]?.id ?? "");
|
||||||
|
const [managerId, setManagerId] = useState(currentUserId);
|
||||||
|
const [cargoDescription, setCargoDescription] = useState("");
|
||||||
|
const [cargoItems, setCargoItems] = useState<CargoItem[]>([{ ...EMPTY_ITEM }]);
|
||||||
|
const [routeFrom, setRouteFrom] = useState("");
|
||||||
|
const [routeTo, setRouteTo] = useState("");
|
||||||
|
const [customsPlace, setCustomsPlace] = useState("");
|
||||||
|
const [transportMode, setTransportMode] = useState<"" | "road" | "rail" | "sea" | "air">("");
|
||||||
|
const [transportType, setTransportType] = useState<
|
||||||
|
"" | "consolidated" | "tent" | "trawl" | "backhaul" | "container"
|
||||||
|
>("");
|
||||||
|
const [deadline, setDeadline] = useState("");
|
||||||
|
const [source, setSource] = useState<"email" | "phone" | "messenger">("email");
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [parsingPackingList, setParsingPackingList] = useState(false);
|
||||||
|
const [packingListWarning, setPackingListWarning] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setCustomerId(customers[0]?.id ?? "");
|
||||||
|
setCargoDescription("");
|
||||||
|
setCargoItems([{ ...EMPTY_ITEM }]);
|
||||||
|
setRouteFrom("");
|
||||||
|
setRouteTo("");
|
||||||
|
setCustomsPlace("");
|
||||||
|
setTransportMode("");
|
||||||
|
setTransportType("");
|
||||||
|
setDeadline("");
|
||||||
|
setFiles([]);
|
||||||
|
setError(null);
|
||||||
|
setPackingListWarning(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateItem(index: number, patch: Partial<CargoItem>) {
|
||||||
|
setCargoItems((items) => items.map((it, i) => (i === index ? { ...it, ...patch } : it)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItem() {
|
||||||
|
setCargoItems((items) => [...items, { ...EMPTY_ITEM }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(index: number) {
|
||||||
|
setCargoItems((items) => items.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePackingListUpload(file: File | undefined) {
|
||||||
|
if (!file) return;
|
||||||
|
setParsingPackingList(true);
|
||||||
|
setPackingListWarning(null);
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("file", file);
|
||||||
|
const res = await fetch("/api/inquiries/parse-packing-list", { method: "POST", body: form });
|
||||||
|
setParsingPackingList(false);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setPackingListWarning("Не удалось разобрать файл");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
items: { quantity?: number; lengthMm?: number; widthMm?: number; heightMm?: number; weightKg?: number }[];
|
||||||
|
warning?: string;
|
||||||
|
};
|
||||||
|
if (data.warning) setPackingListWarning(data.warning);
|
||||||
|
|
||||||
|
if (data.items.length > 0) {
|
||||||
|
const parsedRows: CargoItem[] = data.items.map((it) => ({
|
||||||
|
quantity: it.quantity ? String(it.quantity) : "1",
|
||||||
|
lengthMm: it.lengthMm ? String(it.lengthMm) : "",
|
||||||
|
widthMm: it.widthMm ? String(it.widthMm) : "",
|
||||||
|
heightMm: it.heightMm ? String(it.heightMm) : "",
|
||||||
|
weightKg: it.weightKg ? String(it.weightKg) : "",
|
||||||
|
}));
|
||||||
|
setCargoItems((prev) => {
|
||||||
|
const meaningful = prev.filter((it) => it.lengthMm || it.widthMm || it.heightMm || it.weightKg);
|
||||||
|
return [...meaningful, ...parsedRows];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const preparedItems = cargoItems
|
||||||
|
.filter((it) => it.lengthMm || it.widthMm || it.heightMm || it.weightKg)
|
||||||
|
.map((it) => ({
|
||||||
|
quantity: it.quantity ? Number(it.quantity) : undefined,
|
||||||
|
lengthMm: it.lengthMm ? Number(it.lengthMm) : undefined,
|
||||||
|
widthMm: it.widthMm ? Number(it.widthMm) : undefined,
|
||||||
|
heightMm: it.heightMm ? Number(it.heightMm) : undefined,
|
||||||
|
weightKg: it.weightKg ? Number(it.weightKg) : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const res = await fetch("/api/inquiries", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
customerId,
|
||||||
|
managerId,
|
||||||
|
cargoDescription,
|
||||||
|
cargoItems: preparedItems.length > 0 ? preparedItems : undefined,
|
||||||
|
routeFrom,
|
||||||
|
routeTo,
|
||||||
|
customsPlace: customsPlace || undefined,
|
||||||
|
transportMode: transportMode || undefined,
|
||||||
|
transportType: transportType || undefined,
|
||||||
|
deadline: deadline || undefined,
|
||||||
|
source,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setLoading(false);
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось создать запрос");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inquiry = (await res.json()) as { id: string };
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("file", file);
|
||||||
|
await fetch(`/api/inquiries/${inquiry.id}/attachments`, { method: "POST", body: form }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4 py-8"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.form
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
|
className="card max-h-[90vh] w-full max-w-lg overflow-y-auto p-5"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold">Новый запрос</h2>
|
||||||
|
<button type="button" onClick={onClose} className="text-text-muted hover:text-text">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 grid grid-cols-2 gap-3">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Клиент</span>
|
||||||
|
<SearchableSelect
|
||||||
|
required
|
||||||
|
value={customerId}
|
||||||
|
onChange={setCustomerId}
|
||||||
|
placeholder="Поиск по названию…"
|
||||||
|
options={customers.map((c) => ({ id: c.id, label: c.name }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Менеджер</span>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={managerId}
|
||||||
|
onChange={(e) => setManagerId(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{managers.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Груз</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={cargoDescription}
|
||||||
|
onChange={(e) => setCargoDescription(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-text-muted">Места</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex cursor-pointer items-center gap-1 text-xs font-medium text-accent hover:underline">
|
||||||
|
<FileSpreadsheet size={13} />
|
||||||
|
{parsingPackingList ? "Разбираем…" : "Загрузить упаковочный лист"}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls,.csv,.pdf"
|
||||||
|
className="hidden"
|
||||||
|
disabled={parsingPackingList}
|
||||||
|
onChange={(e) => {
|
||||||
|
void handlePackingListUpload(e.target.files?.[0]);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addItem}
|
||||||
|
className="flex items-center gap-1 text-xs font-medium text-accent hover:underline"
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
Добавить место
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{packingListWarning && <p className="mb-1.5 text-xs text-text-faint">{packingListWarning}</p>}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{cargoItems.map((item, index) => (
|
||||||
|
<div key={index} className="grid grid-cols-[1fr_1fr_1fr_1fr_1fr_auto] gap-1.5">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) => updateItem(index, { quantity: e.target.value })}
|
||||||
|
title="Количество мест"
|
||||||
|
placeholder="Кол-во"
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={item.lengthMm}
|
||||||
|
onChange={(e) => updateItem(index, { lengthMm: e.target.value })}
|
||||||
|
placeholder="Д, мм"
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={item.widthMm}
|
||||||
|
onChange={(e) => updateItem(index, { widthMm: e.target.value })}
|
||||||
|
placeholder="Ш, мм"
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={item.heightMm}
|
||||||
|
onChange={(e) => updateItem(index, { heightMm: e.target.value })}
|
||||||
|
placeholder="В, мм"
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={item.weightKg}
|
||||||
|
onChange={(e) => updateItem(index, { weightKg: e.target.value })}
|
||||||
|
placeholder="Вес, кг"
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeItem(index)}
|
||||||
|
disabled={cargoItems.length === 1}
|
||||||
|
className="rounded-md p-1.5 text-text-faint hover:bg-surface-hover hover:text-danger disabled:opacity-30"
|
||||||
|
title="Удалить место"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 grid grid-cols-2 gap-3">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Место погрузки</span>
|
||||||
|
<AddressAutocomplete required value={routeFrom} onChange={setRouteFrom} />
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Место выгрузки</span>
|
||||||
|
<AddressAutocomplete required value={routeTo} onChange={setRouteTo} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Место таможенного оформления</span>
|
||||||
|
<AddressAutocomplete value={customsPlace} onChange={setCustomsPlace} placeholder="если требуется" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mb-3 grid grid-cols-2 gap-3">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Вид транспорта</span>
|
||||||
|
<select
|
||||||
|
value={transportMode}
|
||||||
|
onChange={(e) => setTransportMode(e.target.value as typeof transportMode)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="road">Авто</option>
|
||||||
|
<option value="rail">ЖД</option>
|
||||||
|
<option value="sea">Море</option>
|
||||||
|
<option value="air">Авиа</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Тип транспорта</span>
|
||||||
|
<select
|
||||||
|
value={transportType}
|
||||||
|
onChange={(e) => setTransportType(e.target.value as typeof transportType)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="consolidated">Сборка</option>
|
||||||
|
<option value="tent">Тент</option>
|
||||||
|
<option value="trawl">Трал</option>
|
||||||
|
<option value="backhaul">Догруз</option>
|
||||||
|
<option value="container">Контейнер</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 grid grid-cols-2 gap-3">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Сроки</span>
|
||||||
|
<input
|
||||||
|
value={deadline}
|
||||||
|
onChange={(e) => setDeadline(e.target.value)}
|
||||||
|
placeholder="напр. до 15.08"
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Источник</span>
|
||||||
|
<select
|
||||||
|
value={source}
|
||||||
|
onChange={(e) => setSource(e.target.value as typeof source)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="email">Почта</option>
|
||||||
|
<option value="phone">Телефон</option>
|
||||||
|
<option value="messenger">Мессенджер</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mb-4 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Файлы</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="flex cursor-pointer items-center gap-1.5 rounded-md border border-border px-3 py-2 text-sm text-text-muted hover:border-accent hover:text-text">
|
||||||
|
<Paperclip size={14} />
|
||||||
|
Прикрепить
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => setFiles((prev) => [...prev, ...Array.from(e.target.files ?? [])])}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{files.length > 0 && <span className="text-xs text-text-faint">{files.length} файл(ов)</span>}
|
||||||
|
</div>
|
||||||
|
{files.length > 0 && (
|
||||||
|
<ul className="mt-1.5 flex flex-col gap-1">
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<li key={i} className="flex items-center justify-between text-xs text-text-muted">
|
||||||
|
<span className="truncate">{f.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFiles((prev) => prev.filter((_, idx) => idx !== i))}
|
||||||
|
className="text-text-faint hover:text-danger"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
|
||||||
|
{loading ? "Создаём…" : "Создать запрос"}
|
||||||
|
</button>
|
||||||
|
</motion.form>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Mail } from "lucide-react";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { listInquiries } from "@/lib/inquiries/service";
|
||||||
|
import { listClients } from "@/lib/counterparties/service";
|
||||||
|
import { listManagers } from "@/lib/users/service";
|
||||||
|
import { listPendingDrafts } from "@/lib/inquiries/drafts";
|
||||||
|
import { InquiriesToolbar } from "./toolbar";
|
||||||
|
import { InquiriesBoard } from "./board";
|
||||||
|
|
||||||
|
const COLUMNS: { status: "new" | "quoted" | "converted" | "declined"; label: string }[] = [
|
||||||
|
{ status: "new", label: "Новые" },
|
||||||
|
{ status: "quoted", label: "Отвечен" },
|
||||||
|
{ status: "converted", label: "В заявке" },
|
||||||
|
{ status: "declined", label: "Отклонён" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default async function InquiriesPage() {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
if (session.user.role === "accountant") redirect("/orders");
|
||||||
|
|
||||||
|
const [inquiries, clients, managers, pendingDrafts] = await Promise.all([
|
||||||
|
listInquiries(),
|
||||||
|
listClients(),
|
||||||
|
listManagers(),
|
||||||
|
listPendingDrafts(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<h1 className="font-display text-xl font-bold tracking-tight">Запросы</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{pendingDrafts.length > 0 && (
|
||||||
|
<Link href="/inquiries/drafts" className="btn btn-ghost">
|
||||||
|
<Mail size={15} />
|
||||||
|
Черновики из писем
|
||||||
|
<span className="rounded-full bg-accent-soft px-1.5 py-0.5 text-xs text-accent-soft-text">
|
||||||
|
{pendingDrafts.length}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<InquiriesToolbar
|
||||||
|
customers={clients.map((c) => ({ id: c.id, name: c.name }))}
|
||||||
|
managers={managers.map((m) => ({ id: m.id, name: m.name }))}
|
||||||
|
currentUserId={session.user.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InquiriesBoard inquiries={inquiries} columns={COLUMNS} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { NewInquiryModal } from "./new-inquiry-modal";
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InquiriesToolbar({
|
||||||
|
customers,
|
||||||
|
managers,
|
||||||
|
currentUserId,
|
||||||
|
}: {
|
||||||
|
customers: Option[];
|
||||||
|
managers: Option[];
|
||||||
|
currentUserId: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-primary" onClick={() => setOpen(true)}>
|
||||||
|
<Plus size={16} />
|
||||||
|
Новый запрос
|
||||||
|
</button>
|
||||||
|
<NewInquiryModal
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
customers={customers}
|
||||||
|
managers={managers}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Truck } from "lucide-react";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { LogoutButton } from "./logout-button";
|
||||||
|
import { NavShell } from "./nav-shell";
|
||||||
|
import { ThemeToggle } from "./theme-toggle";
|
||||||
|
import { NotificationToggle } from "./notification-toggle";
|
||||||
|
import { EventToasts } from "./event-toasts";
|
||||||
|
import { PresenceHeartbeat } from "./presence-heartbeat";
|
||||||
|
import { Avatar } from "./avatar";
|
||||||
|
|
||||||
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = session.user.role as "admin" | "manager" | "accountant";
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Fixed to the real visible viewport height (ViewportHeightFix keeps
|
||||||
|
// --app-vh in sync with window.visualViewport, which — unlike 100vh —
|
||||||
|
// actually shrinks under the on-screen keyboard) so header/nav stay
|
||||||
|
// pinned and only <main> scrolls, the standard "app shell" layout
|
||||||
|
// instead of the whole page scrolling under a header that reflows.
|
||||||
|
<div className="flex flex-col overflow-hidden bg-bg" style={{ height: "var(--app-vh)" }}>
|
||||||
|
<header className="shrink-0 border-b border-border bg-surface pt-[env(safe-area-inset-top)]">
|
||||||
|
<div className="mx-auto flex max-w-[1800px] items-center gap-3 px-4 py-2.5 sm:gap-6 sm:px-6 sm:py-3">
|
||||||
|
<Link href="/inquiries" className="flex shrink-0 items-center gap-2 font-display text-[15px] font-bold tracking-tight">
|
||||||
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-accent text-white">
|
||||||
|
<Truck size={15} strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<span className="hidden sm:inline">Thunderbird Portal</span>
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-1 justify-center overflow-x-auto">
|
||||||
|
<NavShell role={role} />
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-2 sm:ml-0 sm:gap-3">
|
||||||
|
<NotificationToggle />
|
||||||
|
<ThemeToggle />
|
||||||
|
<Link href="/profile" className="flex items-center gap-2 rounded-md px-1.5 py-1 hover:bg-surface-hover">
|
||||||
|
<Avatar userId={session.user.id} name={session.user.name} hasAvatar={Boolean(session.user.avatarPath)} size={26} />
|
||||||
|
<span className="hidden text-sm text-text-muted sm:inline">{session.user.name}</span>
|
||||||
|
</Link>
|
||||||
|
<LogoutButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* pb: clears the fixed mobile bottom-tab bar (~52px + safe-area) so it never covers the last bit of scrolled content */}
|
||||||
|
<main className="app-shell-main mx-auto w-full max-w-[1800px] flex-1 overflow-y-auto px-4 py-6 pb-[calc(4.25rem+env(safe-area-inset-bottom))] sm:px-6 lg:pb-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<EventToasts currentUserId={session.user.id} />
|
||||||
|
<PresenceHeartbeat />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { LogOut } from "lucide-react";
|
||||||
|
|
||||||
|
export function LogoutButton() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
|
router.push("/login");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={handleLogout} className="btn btn-ghost" title="Выйти">
|
||||||
|
<LogOut size={15} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { Inbox, Truck, MessageCircle, ListTodo, MoreHorizontal, X } from "lucide-react";
|
||||||
|
import { links, type Role } from "./nav-data";
|
||||||
|
|
||||||
|
// The 4 most-used sections get their own bottom-bar slot (bar's too narrow
|
||||||
|
// for all 8, and cramming more in defeats the point of a thumb-reachable
|
||||||
|
// tab bar); everything else lives behind "Ещё".
|
||||||
|
const PRIMARY_HREFS = ["/chat", "/orders", "/tasks", "/inquiries"];
|
||||||
|
const PRIMARY_ICONS: Record<string, typeof Inbox> = {
|
||||||
|
"/chat": MessageCircle,
|
||||||
|
"/orders": Truck,
|
||||||
|
"/tasks": ListTodo,
|
||||||
|
"/inquiries": Inbox,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MobileNav({ role, unread }: { role: Role; unread: number }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
|
|
||||||
|
const visibleLinks = links.filter((link) => link.roles.includes(role));
|
||||||
|
const primary = PRIMARY_HREFS.map((href) => visibleLinks.find((l) => l.href === href)).filter((l) => l !== undefined);
|
||||||
|
const overflow = visibleLinks.filter((l) => !PRIMARY_HREFS.includes(l.href));
|
||||||
|
const overflowActive = overflow.some((l) => pathname.startsWith(l.href));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{moreOpen && (
|
||||||
|
<div className="fixed inset-0 z-40 bg-black/40 lg:hidden" onClick={() => setMoreOpen(false)}>
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 rounded-t-xl border-t border-border bg-surface p-2 pb-[calc(0.5rem+env(safe-area-inset-bottom))]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center justify-between px-2 py-1.5">
|
||||||
|
<span className="text-sm font-semibold">Ещё</span>
|
||||||
|
<button onClick={() => setMoreOpen(false)} className="rounded-md p-1 text-text-faint hover:bg-surface-hover">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{overflow.map(({ href, label, icon: Icon }) => {
|
||||||
|
const active = pathname.startsWith(href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
onClick={() => setMoreOpen(false)}
|
||||||
|
className={`flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium ${
|
||||||
|
active ? "bg-accent-soft text-accent-soft-text" : "text-text-muted hover:bg-surface-hover hover:text-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={17} />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<nav
|
||||||
|
className="app-mobile-nav fixed inset-x-0 bottom-0 z-30 flex border-t border-border bg-surface pb-[env(safe-area-inset-bottom)] lg:hidden"
|
||||||
|
aria-label="Основная навигация"
|
||||||
|
>
|
||||||
|
{primary.map(({ href, label, icon: FallbackIcon }) => {
|
||||||
|
const active = pathname.startsWith(href);
|
||||||
|
const Icon = PRIMARY_ICONS[href] ?? FallbackIcon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={`relative flex flex-1 flex-col items-center gap-0.5 py-2 text-[11px] font-medium ${
|
||||||
|
active ? "text-accent" : "text-text-faint"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
{label}
|
||||||
|
{href === "/chat" && unread > 0 && (
|
||||||
|
<span className="absolute right-[22%] top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-danger px-1 text-[10px] font-semibold text-white">
|
||||||
|
{unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => setMoreOpen(true)}
|
||||||
|
className={`flex flex-1 flex-col items-center gap-0.5 py-2 text-[11px] font-medium ${
|
||||||
|
overflowActive ? "text-accent" : "text-text-faint"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={20} />
|
||||||
|
Ещё
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Inbox, Truck, MapPinned, Wallet, BarChart3, Settings, MessageCircle, ListTodo } from "lucide-react";
|
||||||
|
import type { AppEvent } from "@/lib/events/bus";
|
||||||
|
|
||||||
|
export type Role = "admin" | "manager" | "accountant";
|
||||||
|
|
||||||
|
// Role restriction is temporarily disabled everywhere (see requireRole) —
|
||||||
|
// every link is shown to every authenticated user regardless of role.
|
||||||
|
export const links: { href: string; label: string; icon: typeof Inbox; roles: Role[] }[] = [
|
||||||
|
{ href: "/inquiries", label: "Запросы", icon: Inbox, roles: ["admin", "manager", "accountant"] },
|
||||||
|
{ href: "/orders", label: "Заявки", icon: Truck, roles: ["admin", "manager", "accountant"] },
|
||||||
|
{ href: "/tracking", label: "Слежение", icon: MapPinned, roles: ["admin", "manager", "accountant"] },
|
||||||
|
{ href: "/chat", label: "Чат", icon: MessageCircle, roles: ["admin", "manager", "accountant"] },
|
||||||
|
{ href: "/tasks", label: "Задачи", icon: ListTodo, roles: ["admin", "manager", "accountant"] },
|
||||||
|
{ href: "/finance", label: "Финансы", icon: Wallet, roles: ["admin", "manager", "accountant"] },
|
||||||
|
{ href: "/reports", label: "Отчёты", icon: BarChart3, roles: ["admin", "manager", "accountant"] },
|
||||||
|
{ href: "/settings/counterparties", label: "Справочники", icon: Settings, roles: ["admin", "manager", "accountant"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ConversationSummary {
|
||||||
|
unreadCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once from NavShell and passed down to both the desktop top nav and
|
||||||
|
* the mobile bottom bar — calling this hook from each of them separately
|
||||||
|
* would open two independent EventSource connections for the same data.
|
||||||
|
*/
|
||||||
|
export function useUnreadChatCount(): number {
|
||||||
|
const [unread, setUnread] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function refresh() {
|
||||||
|
const res = await fetch("/api/chat/conversations");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const convs = (await res.json()) as ConversationSummary[];
|
||||||
|
if (!cancelled) setUnread(convs.reduce((acc, c) => acc + c.unreadCount, 0));
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
const source = new EventSource("/api/events");
|
||||||
|
source.onmessage = (msg) => {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(msg.data) as AppEvent;
|
||||||
|
if (event.type === "chat.message") refresh();
|
||||||
|
} catch {
|
||||||
|
// ignore malformed events / heartbeat comments
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
source.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keeps the installed-PWA icon badge in sync while the app is open (the
|
||||||
|
// service worker's push handler covers the same thing while it's closed
|
||||||
|
// — see public/sw.js). Android/desktop only; iOS Safari has no Badging
|
||||||
|
// API, so "setAppBadge" in navigator is false there and this no-ops.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!("setAppBadge" in navigator)) return;
|
||||||
|
if (unread > 0) {
|
||||||
|
void navigator.setAppBadge(unread);
|
||||||
|
} else {
|
||||||
|
void navigator.clearAppBadge();
|
||||||
|
}
|
||||||
|
}, [unread]);
|
||||||
|
|
||||||
|
return unread;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { links, type Role } from "./nav-data";
|
||||||
|
|
||||||
|
export function NavLinks({ role, unread }: { role: Role; unread: number }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const visibleLinks = links.filter((link) => link.roles.includes(role));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex items-center gap-1">
|
||||||
|
{visibleLinks.map(({ href, label, icon: Icon }) => {
|
||||||
|
const active = pathname.startsWith(href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={`relative flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||||
|
active ? "bg-accent-soft text-accent-soft-text" : "text-text-muted hover:bg-surface-hover hover:text-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={15} />
|
||||||
|
{label}
|
||||||
|
{href === "/chat" && unread > 0 && (
|
||||||
|
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-danger px-1 text-[10px] font-semibold text-white">
|
||||||
|
{unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { NavLinks } from "./nav-links";
|
||||||
|
import { MobileNav } from "./mobile-nav";
|
||||||
|
import { useUnreadChatCount, type Role } from "./nav-data";
|
||||||
|
|
||||||
|
/** Renders both the desktop top nav and the mobile bottom bar, feeding them one shared unread-count subscription. */
|
||||||
|
export function NavShell({ role }: { role: Role }) {
|
||||||
|
const unread = useUnreadChatCount();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="hidden lg:flex">
|
||||||
|
<NavLinks role={role} unread={unread} />
|
||||||
|
</div>
|
||||||
|
<MobileNav role={role} unread={unread} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Bell, BellOff } from "lucide-react";
|
||||||
|
import { ensurePushSubscribed } from "@/lib/push/client";
|
||||||
|
|
||||||
|
function initialPermission(): NotificationPermission | "unsupported" {
|
||||||
|
if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
|
||||||
|
return Notification.permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationToggle() {
|
||||||
|
const [permission, setPermission] = useState(initialPermission);
|
||||||
|
|
||||||
|
// Returning user who already granted permission in an earlier session —
|
||||||
|
// make sure the push subscription is (still) registered, not just the
|
||||||
|
// notification permission.
|
||||||
|
useEffect(() => {
|
||||||
|
if (permission === "granted") void ensurePushSubscribed();
|
||||||
|
}, [permission]);
|
||||||
|
|
||||||
|
if (permission === "unsupported") return null;
|
||||||
|
|
||||||
|
async function requestPermission() {
|
||||||
|
if (permission === "granted") return; // no API to revoke it programmatically — only the browser's own site settings can
|
||||||
|
const next = await Notification.requestPermission();
|
||||||
|
setPermission(next);
|
||||||
|
if (next === "granted") void ensurePushSubscribed();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={requestPermission}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
title={permission === "granted" ? "Уведомления включены" : "Включить уведомления"}
|
||||||
|
>
|
||||||
|
{permission === "granted" ? <Bell size={15} /> : <BellOff size={15} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { FileText, Download } from "lucide-react";
|
||||||
|
|
||||||
|
interface Template {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Attachment {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PoaFields {
|
||||||
|
flightNumber: string;
|
||||||
|
poaDriverName: string;
|
||||||
|
poaDriverPassport: string;
|
||||||
|
poaVehiclePlate: string;
|
||||||
|
poaVehicleModel: string;
|
||||||
|
poaValidUntil: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABEL: Record<string, string> = {
|
||||||
|
client_docs: "Доки для клиента",
|
||||||
|
expense_docs: "Расход",
|
||||||
|
photo: "Фото",
|
||||||
|
insurance: "Страхование",
|
||||||
|
other: "Прочее",
|
||||||
|
};
|
||||||
|
|
||||||
|
function toDateInputValue(date: Date | string | null): string {
|
||||||
|
if (!date) return "";
|
||||||
|
const d = new Date(date);
|
||||||
|
const yyyy = d.getFullYear();
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${yyyy}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentsSection({
|
||||||
|
orderId,
|
||||||
|
templates,
|
||||||
|
attachments,
|
||||||
|
poaFields,
|
||||||
|
}: {
|
||||||
|
orderId: string;
|
||||||
|
templates: Template[];
|
||||||
|
attachments: Attachment[];
|
||||||
|
poaFields: {
|
||||||
|
flightNumber: string | null;
|
||||||
|
poaDriverName: string | null;
|
||||||
|
poaDriverPassport: string | null;
|
||||||
|
poaVehiclePlate: string | null;
|
||||||
|
poaVehicleModel: string | null;
|
||||||
|
poaValidUntil: Date | string | null;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [templateId, setTemplateId] = useState(templates[0]?.id ?? "");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [fields, setFields] = useState<PoaFields>({
|
||||||
|
flightNumber: poaFields.flightNumber ?? "",
|
||||||
|
poaDriverName: poaFields.poaDriverName ?? "",
|
||||||
|
poaDriverPassport: poaFields.poaDriverPassport ?? "",
|
||||||
|
poaVehiclePlate: poaFields.poaVehiclePlate ?? "",
|
||||||
|
poaVehicleModel: poaFields.poaVehicleModel ?? "",
|
||||||
|
poaValidUntil: toDateInputValue(poaFields.poaValidUntil),
|
||||||
|
});
|
||||||
|
const [fieldsLoading, setFieldsLoading] = useState(false);
|
||||||
|
const [fieldsSaved, setFieldsSaved] = useState(false);
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
if (!templateId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch(`/api/orders/${orderId}/documents/generate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ templateId }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось сгенерировать документ");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFields() {
|
||||||
|
setFieldsLoading(true);
|
||||||
|
setFieldsSaved(false);
|
||||||
|
const res = await fetch(`/api/orders/${orderId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...fields, poaValidUntil: fields.poaValidUntil || undefined }),
|
||||||
|
});
|
||||||
|
setFieldsLoading(false);
|
||||||
|
if (res.ok) {
|
||||||
|
setFieldsSaved(true);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-muted">Документы</h2>
|
||||||
|
|
||||||
|
<div className="mb-3 flex flex-col gap-1.5">
|
||||||
|
{attachments.map((a) => (
|
||||||
|
<a
|
||||||
|
key={a.id}
|
||||||
|
href={`/api/attachments/${a.id}`}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm hover:border-accent"
|
||||||
|
>
|
||||||
|
<FileText size={14} className="shrink-0 text-text-muted" />
|
||||||
|
<span className="flex-1 truncate">{a.fileName}</span>
|
||||||
|
<span className="text-xs text-text-faint">{CATEGORY_LABEL[a.category] ?? a.category}</span>
|
||||||
|
<Download size={14} className="shrink-0 text-text-muted" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
{attachments.length === 0 && <p className="text-sm text-text-faint">Пока нет документов.</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="mb-3 border-t border-border pt-3">
|
||||||
|
<summary className="cursor-pointer text-sm font-medium text-text-muted">
|
||||||
|
Рейс, водитель и авто (для доверенности, расписки, договора-заявки)
|
||||||
|
</summary>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block text-text-muted">Рейс №</span>
|
||||||
|
<input
|
||||||
|
value={fields.flightNumber}
|
||||||
|
onChange={(e) => setFields((f) => ({ ...f, flightNumber: e.target.value }))}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block text-text-muted">Доверенность действует до</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={fields.poaValidUntil}
|
||||||
|
onChange={(e) => setFields((f) => ({ ...f, poaValidUntil: e.target.value }))}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block text-text-muted">Водитель (ФИО)</span>
|
||||||
|
<input
|
||||||
|
value={fields.poaDriverName}
|
||||||
|
onChange={(e) => setFields((f) => ({ ...f, poaDriverName: e.target.value }))}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block text-text-muted">Паспорт водителя</span>
|
||||||
|
<input
|
||||||
|
value={fields.poaDriverPassport}
|
||||||
|
onChange={(e) => setFields((f) => ({ ...f, poaDriverPassport: e.target.value }))}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block text-text-muted">Марка ТС</span>
|
||||||
|
<input
|
||||||
|
value={fields.poaVehicleModel}
|
||||||
|
onChange={(e) => setFields((f) => ({ ...f, poaVehicleModel: e.target.value }))}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block text-text-muted">Гос. номер ТС</span>
|
||||||
|
<input
|
||||||
|
value={fields.poaVehiclePlate}
|
||||||
|
onChange={(e) => setFields((f) => ({ ...f, poaVehiclePlate: e.target.value }))}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-3">
|
||||||
|
<button type="button" onClick={saveFields} disabled={fieldsLoading} className="btn btn-ghost">
|
||||||
|
{fieldsLoading ? "Сохраняем…" : "Сохранить"}
|
||||||
|
</button>
|
||||||
|
{fieldsSaved && <span className="text-sm text-text-muted">Сохранено</span>}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{templates.length > 0 ? (
|
||||||
|
<div className="flex items-end gap-2 border-t border-border pt-3">
|
||||||
|
<label className="flex-1 text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Сформировать документ</span>
|
||||||
|
<select
|
||||||
|
value={templateId}
|
||||||
|
onChange={(e) => setTemplateId(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{templates.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button onClick={generate} disabled={loading} className="btn btn-primary">
|
||||||
|
{loading ? "Формируем…" : "Сформировать"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="border-t border-border pt-3 text-sm text-text-faint">
|
||||||
|
Нет шаблонов — загрузите в Настройки → Шаблоны.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && <p className="mt-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface OrderEvent {
|
||||||
|
id: string;
|
||||||
|
body: string;
|
||||||
|
kind: string;
|
||||||
|
createdAt: string | Date;
|
||||||
|
author?: { name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsSection({ orderId, events }: { orderId: string; events: OrderEvent[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!body.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
await fetch(`/api/orders/${orderId}/events`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ body }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
setBody("");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-muted">События и комментарии</h2>
|
||||||
|
<div className="mb-3 flex flex-col gap-2">
|
||||||
|
{events.map((ev) => (
|
||||||
|
<div key={ev.id} className="rounded-md border border-border px-3 py-2 text-sm">
|
||||||
|
<div className="mb-0.5 flex items-center justify-between text-xs text-text-faint">
|
||||||
|
<span>{ev.kind === "system" ? "Система" : ev.author?.name ?? "—"}</span>
|
||||||
|
<span>{new Date(ev.createdAt).toLocaleString("ru-RU")}</span>
|
||||||
|
</div>
|
||||||
|
{ev.body}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{events.length === 0 && <p className="text-sm text-text-faint">Пока нет событий.</p>}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={submit} className="flex items-end gap-2">
|
||||||
|
<input
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder="Комментарий…"
|
||||||
|
className="flex-1 rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||||
|
Отправить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface ExpenseLine {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
sum: number;
|
||||||
|
vatRate: number;
|
||||||
|
paidStatus: "unpaid" | "partial" | "paid";
|
||||||
|
carrier?: { name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpenseSection({
|
||||||
|
orderId,
|
||||||
|
lines,
|
||||||
|
carriers,
|
||||||
|
}: {
|
||||||
|
orderId: string;
|
||||||
|
lines: ExpenseLine[];
|
||||||
|
carriers: { id: string; name: string }[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [carrierId, setCarrierId] = useState(carriers[0]?.id ?? "");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [sum, setSum] = useState("");
|
||||||
|
const [vatRate, setVatRate] = useState("22");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function addLine(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
await fetch(`/api/orders/${orderId}/expense`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ carrierId: carrierId || undefined, description, sum: Number(sum), vatRate: Number(vatRate) }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
setDescription("");
|
||||||
|
setSum("");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchLine(id: string, body: Record<string, unknown>) {
|
||||||
|
await fetch(`/api/orders/${orderId}/expense/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = lines.reduce((acc, l) => acc + l.sum * (1 + l.vatRate / 100), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-muted">Расходы (подрядчикам)</h2>
|
||||||
|
<div className="mb-3 flex flex-col gap-2">
|
||||||
|
{lines.map((l) => (
|
||||||
|
<div key={l.id} className="flex flex-wrap items-center gap-3 rounded-md border border-border px-3 py-2 text-sm">
|
||||||
|
<span className="w-32 shrink-0 truncate text-text-muted">{l.carrier?.name ?? "—"}</span>
|
||||||
|
<span className="flex-1 min-w-32">{l.description}</span>
|
||||||
|
<span className="font-medium">{(l.sum * (1 + l.vatRate / 100)).toLocaleString("ru-RU")} ₽</span>
|
||||||
|
<select
|
||||||
|
value={l.paidStatus}
|
||||||
|
onChange={(e) => patchLine(l.id, { paidStatus: e.target.value })}
|
||||||
|
className="rounded-md border border-border bg-surface px-2 py-1 text-xs outline-none"
|
||||||
|
>
|
||||||
|
<option value="unpaid">Не оплачен</option>
|
||||||
|
<option value="partial">Частично</option>
|
||||||
|
<option value="paid">Оплачен</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{lines.length === 0 && <p className="text-sm text-text-faint">Пока нет строк расхода.</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={addLine} className="flex flex-wrap items-end gap-2 border-t border-border pt-3">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Перевозчик</span>
|
||||||
|
<select
|
||||||
|
value={carrierId}
|
||||||
|
onChange={(e) => setCarrierId(e.target.value)}
|
||||||
|
className="rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{carriers.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex-1 text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Описание</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Сумма без НДС</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
value={sum}
|
||||||
|
onChange={(e) => setSum(e.target.value)}
|
||||||
|
className="w-32 rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">НДС %</span>
|
||||||
|
<select
|
||||||
|
value={vatRate}
|
||||||
|
onChange={(e) => setVatRate(e.target.value)}
|
||||||
|
className="rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="0">0</option>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="20">20</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-3 text-right text-sm text-text-muted">
|
||||||
|
Итого с НДС: <span className="font-semibold text-text">{total.toLocaleString("ru-RU")} ₽</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface IncomeLine {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
sum: number;
|
||||||
|
vatRate: number;
|
||||||
|
paidStatus: "unpaid" | "partial" | "paid";
|
||||||
|
docsSigned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IncomeSection({ orderId, lines }: { orderId: string; lines: IncomeLine[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [sum, setSum] = useState("");
|
||||||
|
const [vatRate, setVatRate] = useState("22");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function addLine(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
await fetch(`/api/orders/${orderId}/income`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ description, sum: Number(sum), vatRate: Number(vatRate) }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
setDescription("");
|
||||||
|
setSum("");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchLine(id: string, body: Record<string, unknown>) {
|
||||||
|
await fetch(`/api/orders/${orderId}/income/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = lines.reduce((acc, l) => acc + l.sum * (1 + l.vatRate / 100), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-muted">Доходы (клиенту)</h2>
|
||||||
|
<div className="mb-3 flex flex-col gap-2">
|
||||||
|
{lines.map((l) => (
|
||||||
|
<div key={l.id} className="flex flex-wrap items-center gap-3 rounded-md border border-border px-3 py-2 text-sm">
|
||||||
|
<span className="flex-1 min-w-32">{l.description}</span>
|
||||||
|
<span className="font-medium">{(l.sum * (1 + l.vatRate / 100)).toLocaleString("ru-RU")} ₽</span>
|
||||||
|
<select
|
||||||
|
value={l.paidStatus}
|
||||||
|
onChange={(e) => patchLine(l.id, { paidStatus: e.target.value })}
|
||||||
|
className="rounded-md border border-border bg-surface px-2 py-1 text-xs outline-none"
|
||||||
|
>
|
||||||
|
<option value="unpaid">Не оплачен</option>
|
||||||
|
<option value="partial">Частично</option>
|
||||||
|
<option value="paid">Оплачен</option>
|
||||||
|
</select>
|
||||||
|
<label className="flex items-center gap-1 text-xs text-text-muted">
|
||||||
|
<input type="checkbox" checked={l.docsSigned} onChange={(e) => patchLine(l.id, { docsSigned: e.target.checked })} />
|
||||||
|
Документы подписаны
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{lines.length === 0 && <p className="text-sm text-text-faint">Пока нет строк дохода.</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={addLine} className="flex flex-wrap items-end gap-2 border-t border-border pt-3">
|
||||||
|
<label className="flex-1 text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Описание</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Сумма без НДС</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
value={sum}
|
||||||
|
onChange={(e) => setSum(e.target.value)}
|
||||||
|
className="w-32 rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">НДС %</span>
|
||||||
|
<select
|
||||||
|
value={vatRate}
|
||||||
|
onChange={(e) => setVatRate(e.target.value)}
|
||||||
|
className="rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="0">0</option>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="20">20</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-3 text-right text-sm text-text-muted">
|
||||||
|
Итого с НДС: <span className="font-semibold text-text">{total.toLocaleString("ru-RU")} ₽</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { getOrder, computeMargin } from "@/lib/orders/service";
|
||||||
|
import { canAccessOrder } from "@/lib/orders/visibility";
|
||||||
|
import { listCarriers } from "@/lib/counterparties/service";
|
||||||
|
import { listTemplates } from "@/lib/documents/templates";
|
||||||
|
import { StatusControls } from "./status-controls";
|
||||||
|
import { IncomeSection } from "./income-section";
|
||||||
|
import { ExpenseSection } from "./expense-section";
|
||||||
|
import { EventsSection } from "./events-section";
|
||||||
|
import { DocumentsSection } from "./documents-section";
|
||||||
|
|
||||||
|
export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const order = await getOrder(id);
|
||||||
|
if (!order) notFound();
|
||||||
|
if (!canAccessOrder(order, session.user)) redirect("/orders");
|
||||||
|
|
||||||
|
const [carriers, templates] = await Promise.all([listCarriers(), listTemplates()]);
|
||||||
|
const margin = computeMargin(order);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Link href="/orders" className="mb-4 inline-flex items-center gap-1 text-sm text-text-muted hover:text-text">
|
||||||
|
<ArrowLeft size={14} />
|
||||||
|
Заявки
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-xl font-bold tracking-tight">{order.number}</h1>
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
{order.customer?.name} · менеджер {order.manager?.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href={`/t/${order.publicToken}`} target="_blank" className="text-xs text-accent hover:underline">
|
||||||
|
Ссылка для клиента
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-5 card p-4">
|
||||||
|
<StatusControls
|
||||||
|
orderId={order.id}
|
||||||
|
status={order.status}
|
||||||
|
paidStatus={order.paidStatus}
|
||||||
|
docsReceived={order.docsReceived}
|
||||||
|
edoUploaded={order.edoUploaded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-5 grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
<div className="card p-4 lg:col-span-2">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-muted">Груз и маршрут</h2>
|
||||||
|
<dl className="grid grid-cols-2 gap-y-2 text-sm">
|
||||||
|
<dt className="text-text-muted">Груз</dt>
|
||||||
|
<dd>{order.cargoDescription}</dd>
|
||||||
|
<dt className="text-text-muted">Маршрут</dt>
|
||||||
|
<dd>
|
||||||
|
{order.routeFrom} → {order.routeTo}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div className="card p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-muted">Маржа</h2>
|
||||||
|
<dl className="flex flex-col gap-1 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-text-muted">Доход (с НДС)</dt>
|
||||||
|
<dd>{margin.incomeTotal.toLocaleString("ru-RU")} ₽</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-text-muted">Расход (с НДС)</dt>
|
||||||
|
<dd>{margin.expenseTotal.toLocaleString("ru-RU")} ₽</dd>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex justify-between border-t border-border pt-1 font-semibold">
|
||||||
|
<dt>Дельта (с НДС)</dt>
|
||||||
|
<dd>{margin.marginWithVat.toLocaleString("ru-RU")} ₽</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-text-muted">
|
||||||
|
<dt>Дельта (без НДС)</dt>
|
||||||
|
<dd>{margin.marginNoVat.toLocaleString("ru-RU")} ₽</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-5 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<IncomeSection orderId={order.id} lines={order.incomeLines} />
|
||||||
|
<ExpenseSection
|
||||||
|
orderId={order.id}
|
||||||
|
lines={order.expenseLines}
|
||||||
|
carriers={carriers.map((c) => ({ id: c.id, name: c.name }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-5">
|
||||||
|
<DocumentsSection
|
||||||
|
orderId={order.id}
|
||||||
|
templates={templates.map((t) => ({ id: t.id, name: t.name }))}
|
||||||
|
attachments={order.attachments.map((a) => ({ id: a.id, fileName: a.fileName, category: a.category }))}
|
||||||
|
poaFields={{
|
||||||
|
flightNumber: order.flightNumber,
|
||||||
|
poaDriverName: order.poaDriverName,
|
||||||
|
poaDriverPassport: order.poaDriverPassport,
|
||||||
|
poaVehiclePlate: order.poaVehiclePlate,
|
||||||
|
poaVehicleModel: order.poaVehicleModel,
|
||||||
|
poaValidUntil: order.poaValidUntil,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EventsSection
|
||||||
|
orderId={order.id}
|
||||||
|
events={order.events.map((e) => ({ id: e.id, body: e.body, kind: e.kind, createdAt: e.createdAt, author: e.author }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: "new", label: "Новая" },
|
||||||
|
{ value: "in_progress", label: "На исполнении" },
|
||||||
|
{ value: "done", label: "Завершена" },
|
||||||
|
{ value: "closed", label: "Закрыта" },
|
||||||
|
];
|
||||||
|
const PAID_OPTIONS = [
|
||||||
|
{ value: "unpaid", label: "Не оплачен" },
|
||||||
|
{ value: "partial", label: "Частично" },
|
||||||
|
{ value: "paid", label: "Оплачен" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function StatusControls({
|
||||||
|
orderId,
|
||||||
|
status,
|
||||||
|
paidStatus,
|
||||||
|
docsReceived,
|
||||||
|
edoUploaded,
|
||||||
|
}: {
|
||||||
|
orderId: string;
|
||||||
|
status: string;
|
||||||
|
paidStatus: string;
|
||||||
|
docsReceived: boolean;
|
||||||
|
edoUploaded: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function patch(body: Record<string, unknown>) {
|
||||||
|
setLoading(true);
|
||||||
|
await fetch(`/api/orders/${orderId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<span className="text-text-muted">Статус</span>
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={(e) => patch({ status: e.target.value })}
|
||||||
|
className="rounded-md border border-border bg-surface px-2 py-1.5 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<span className="text-text-muted">Оплата</span>
|
||||||
|
<select
|
||||||
|
value={paidStatus}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={(e) => patch({ paidStatus: e.target.value })}
|
||||||
|
className="rounded-md border border-border bg-surface px-2 py-1.5 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{PAID_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<input type="checkbox" checked={docsReceived} disabled={loading} onChange={(e) => patch({ docsReceived: e.target.checked })} />
|
||||||
|
<span className="text-text-muted">Документы получены</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<input type="checkbox" checked={edoUploaded} disabled={loading} onChange={(e) => patch({ edoUploaded: e.target.checked })} />
|
||||||
|
<span className="text-text-muted">Выгружено в ЭДО</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { listOrders, computeMargin } from "@/lib/orders/service";
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
new: "Новая",
|
||||||
|
in_progress: "На исполнении",
|
||||||
|
done: "Завершена",
|
||||||
|
closed: "Закрыта",
|
||||||
|
};
|
||||||
|
const STATUS_BADGE: Record<string, string> = {
|
||||||
|
new: "badge-new",
|
||||||
|
in_progress: "badge-open",
|
||||||
|
done: "badge-done",
|
||||||
|
closed: "badge-closed",
|
||||||
|
};
|
||||||
|
const PAID_LABEL: Record<string, string> = { unpaid: "Не оплачен", partial: "Частично", paid: "Оплачен" };
|
||||||
|
|
||||||
|
export default async function OrdersPage() {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const orders = await listOrders({ id: session.user.id, role: session.user.role as "admin" | "manager" | "accountant" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-5 font-display text-xl font-bold tracking-tight">Заявки</h1>
|
||||||
|
|
||||||
|
<div className="card overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left text-text-muted">
|
||||||
|
<th className="px-4 py-3 font-medium">Номер</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Клиент</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Маршрут</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Менеджер</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Статус</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Оплата</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-right">Маржа</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{orders.map((o) => {
|
||||||
|
const margin = computeMargin(o);
|
||||||
|
return (
|
||||||
|
<tr key={o.id} className="border-b border-border last:border-0 hover:bg-surface-hover">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link href={`/orders/${o.id}`} className="font-mono text-xs text-accent hover:underline">
|
||||||
|
{o.number}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{o.customer?.name}</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">
|
||||||
|
{o.routeFrom} → {o.routeTo}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">{o.manager?.name}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`badge ${STATUS_BADGE[o.status]}`}>{STATUS_LABEL[o.status]}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">{PAID_LABEL[o.paidStatus]}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-medium">{margin.marginWithVat.toLocaleString("ru-RU")} ₽</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{orders.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-8 text-center text-text-faint">
|
||||||
|
Пока нет заявок. Создайте их из запроса.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
const INTERVAL_MS = 20_000;
|
||||||
|
|
||||||
|
/** Держит lastSeenAt свежим, пока вкладка открыта и видима — "онлайн" на клиенте вычисляется как lastSeenAt моложе ~30с. */
|
||||||
|
export function PresenceHeartbeat() {
|
||||||
|
useEffect(() => {
|
||||||
|
function ping() {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
fetch("/api/presence/heartbeat", { method: "POST" }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ping();
|
||||||
|
const interval = setInterval(ping, INTERVAL_MS);
|
||||||
|
document.addEventListener("visibilitychange", ping);
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
document.removeEventListener("visibilitychange", ping);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Avatar } from "../avatar";
|
||||||
|
|
||||||
|
export function AvatarUploader({ userId, name, hasAvatar }: { userId: string; name: string; hasAvatar: boolean }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("file", file);
|
||||||
|
const res = await fetch("/api/profile/avatar", { method: "POST", body: form });
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось загрузить аватарку");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar userId={userId} name={name} hasAvatar={hasAvatar} size={72} />
|
||||||
|
<div>
|
||||||
|
<button type="button" onClick={() => inputRef.current?.click()} disabled={loading} className="btn btn-primary">
|
||||||
|
{loading ? "Загружаем…" : "Загрузить аватарку"}
|
||||||
|
</button>
|
||||||
|
<input ref={inputRef} type="file" accept="image/png,image/jpeg,image/webp,image/gif" hidden onChange={handleChange} />
|
||||||
|
{error && <p className="mt-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { AvatarUploader } from "./avatar-uploader";
|
||||||
|
|
||||||
|
const ROLE_LABEL: Record<string, string> = { admin: "Администратор", manager: "Менеджер", accountant: "Бухгалтер" };
|
||||||
|
|
||||||
|
export default async function ProfilePage() {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const { user } = session;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-lg">
|
||||||
|
<h1 className="mb-5 font-display text-xl font-bold tracking-tight">Профиль</h1>
|
||||||
|
|
||||||
|
<div className="card mb-5 p-4">
|
||||||
|
<AvatarUploader userId={user.id} name={user.name} hasAvatar={Boolean(user.avatarPath)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-4">
|
||||||
|
<dl className="flex flex-col gap-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-text-muted">Имя</dt>
|
||||||
|
<dd>{user.name}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-text-muted">Email</dt>
|
||||||
|
<dd>{user.email}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-text-muted">Роль</dt>
|
||||||
|
<dd>{ROLE_LABEL[user.role]}</dd>
|
||||||
|
</div>
|
||||||
|
{user.managerNumber != null && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-text-muted">Номер менеджера</dt>
|
||||||
|
<dd>{user.managerNumber}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Download } from "lucide-react";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { managerReport, periodicReport, inTransitToday, requestSlaReport } from "@/lib/reports/service";
|
||||||
|
|
||||||
|
const PERIODS: Record<string, { days: number; label: string }> = {
|
||||||
|
week: { days: 7, label: "Неделя" },
|
||||||
|
month: { days: 30, label: "Месяц" },
|
||||||
|
quarter: { days: 90, label: "Квартал" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatMinutes(min: number | null): string {
|
||||||
|
if (min == null) return "—";
|
||||||
|
if (min < 60) return `${Math.round(min)} мин`;
|
||||||
|
return `${(min / 60).toFixed(1)} ч`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ReportsPage({ searchParams }: { searchParams: Promise<{ period?: string }> }) {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
if (session.user.role === "manager") redirect("/orders");
|
||||||
|
|
||||||
|
const { period: periodParam } = await searchParams;
|
||||||
|
const period = periodParam && PERIODS[periodParam] ? periodParam : "week";
|
||||||
|
|
||||||
|
const [managers, periodic, inTransit, sla] = await Promise.all([
|
||||||
|
managerReport(),
|
||||||
|
periodicReport(PERIODS[period].days),
|
||||||
|
inTransitToday(),
|
||||||
|
requestSlaReport(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<h1 className="font-display text-xl font-bold tracking-tight">Отчёты</h1>
|
||||||
|
<a href={`/api/reports/export?period=${period}`} className="btn btn-ghost">
|
||||||
|
<Download size={15} />
|
||||||
|
Экспорт в Excel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-text-muted">Период</h2>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{Object.entries(PERIODS).map(([key, p]) => (
|
||||||
|
<Link
|
||||||
|
key={key}
|
||||||
|
href={`/reports?period=${key}`}
|
||||||
|
className={`rounded-md px-3 py-1.5 text-sm font-medium ${period === key ? "bg-accent-soft text-accent-soft-text" : "text-text-muted hover:bg-surface-hover"}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||||
|
<div className="card p-4">
|
||||||
|
<p className="mb-1 text-xs text-text-muted">Заявок за период</p>
|
||||||
|
<p className="text-xl font-semibold">{periodic.orderCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="card p-4">
|
||||||
|
<p className="mb-1 text-xs text-text-muted">Оборот</p>
|
||||||
|
<p className="text-xl font-semibold">{periodic.turnover.toLocaleString("ru-RU")} ₽</p>
|
||||||
|
</div>
|
||||||
|
<div className="card p-4">
|
||||||
|
<p className="mb-1 text-xs text-text-muted">Маржа</p>
|
||||||
|
<p className="text-xl font-semibold">{periodic.margin.toLocaleString("ru-RU")} ₽</p>
|
||||||
|
</div>
|
||||||
|
<div className="card p-4">
|
||||||
|
<p className="mb-1 text-xs text-text-muted">Выполнено</p>
|
||||||
|
<p className="text-xl font-semibold">{periodic.done}</p>
|
||||||
|
</div>
|
||||||
|
<div className="card p-4">
|
||||||
|
<p className="mb-1 text-xs text-text-muted">Закрыто</p>
|
||||||
|
<p className="text-xl font-semibold">{periodic.closed}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<div className="card p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-muted">По менеджерам (для премий)</h2>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left text-text-muted">
|
||||||
|
<th className="py-2 font-medium">Менеджер</th>
|
||||||
|
<th className="py-2 font-medium text-right">Заявок</th>
|
||||||
|
<th className="py-2 font-medium text-right">Маржа</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{managers.map((m) => (
|
||||||
|
<tr key={m.manager.id} className="border-b border-border last:border-0">
|
||||||
|
<td className="py-2">{m.manager.name}</td>
|
||||||
|
<td className="py-2 text-right">{m.orderCount}</td>
|
||||||
|
<td className="py-2 text-right font-medium">{m.marginWithVat.toLocaleString("ru-RU")} ₽</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-muted">SLA по запросам</h2>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left text-text-muted">
|
||||||
|
<th className="py-2 font-medium">Менеджер</th>
|
||||||
|
<th className="py-2 font-medium text-right">Ср. время ответа</th>
|
||||||
|
<th className="py-2 font-medium text-right">Без ответа</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sla.map((s) => (
|
||||||
|
<tr key={s.manager.id} className="border-b border-border last:border-0">
|
||||||
|
<td className="py-2">{s.manager.name}</td>
|
||||||
|
<td className="py-2 text-right">{formatMinutes(s.avgResponseMinutes)}</td>
|
||||||
|
<td className={`py-2 text-right ${s.pending > 0 ? "font-semibold text-warning" : ""}`}>{s.pending}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-muted">Заявки в пути сегодня</h2>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{inTransit.map((o) => (
|
||||||
|
<Link
|
||||||
|
key={o.id}
|
||||||
|
href={`/orders/${o.id}`}
|
||||||
|
className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm hover:border-accent"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-xs text-text-faint">{o.number}</span>
|
||||||
|
<span>{o.customer?.name}</span>
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{o.routeFrom} → {o.routeTo}
|
||||||
|
</span>
|
||||||
|
<span className="text-text-faint">{o.manager?.name}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{inTransit.length === 0 && <p className="text-sm text-text-faint">Сейчас ничего не в пути.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
name: string;
|
||||||
|
inn: string;
|
||||||
|
kpp: string;
|
||||||
|
ogrn: string;
|
||||||
|
legalAddress: string;
|
||||||
|
postalAddress: string | null;
|
||||||
|
bankName: string;
|
||||||
|
bankAccount: string;
|
||||||
|
bankBik: string;
|
||||||
|
bankCorrAccount: string;
|
||||||
|
email: string | null;
|
||||||
|
signatoryName: string;
|
||||||
|
signatoryPosition: string;
|
||||||
|
signatoryBasis: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELDS: { key: keyof Settings; label: string; span?: 1 | 2; required?: boolean }[] = [
|
||||||
|
{ key: "name", label: "Название", span: 2, required: true },
|
||||||
|
{ key: "inn", label: "ИНН", required: true },
|
||||||
|
{ key: "kpp", label: "КПП", required: true },
|
||||||
|
{ key: "ogrn", label: "ОГРН", required: true },
|
||||||
|
{ key: "email", label: "E-mail" },
|
||||||
|
{ key: "legalAddress", label: "Юридический адрес", span: 2, required: true },
|
||||||
|
{ key: "postalAddress", label: "Почтовый адрес (если отличается)", span: 2 },
|
||||||
|
{ key: "bankName", label: "Банк", span: 2, required: true },
|
||||||
|
{ key: "bankAccount", label: "Расчётный счёт", required: true },
|
||||||
|
{ key: "bankBik", label: "БИК", required: true },
|
||||||
|
{ key: "bankCorrAccount", label: "Корр. счёт", required: true },
|
||||||
|
{ key: "signatoryName", label: "Подписант (ФИО)", span: 2, required: true },
|
||||||
|
{ key: "signatoryPosition", label: "Должность подписанта", required: true },
|
||||||
|
{ key: "signatoryBasis", label: "Основание полномочий", required: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CompanySettingsForm({ initial }: { initial: Settings | undefined }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [values, setValues] = useState<Record<string, string>>(() => {
|
||||||
|
const v: Record<string, string> = {};
|
||||||
|
for (const f of FIELDS) v[f.key] = (initial?.[f.key] as string) ?? "";
|
||||||
|
if (!initial) {
|
||||||
|
v.signatoryPosition = "генерального директора";
|
||||||
|
v.signatoryBasis = "Устава";
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSaved(false);
|
||||||
|
|
||||||
|
const res = await fetch("/api/settings/company", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось сохранить");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaved(true);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="card grid grid-cols-1 gap-3 p-4 sm:grid-cols-2">
|
||||||
|
{FIELDS.map((f) => (
|
||||||
|
<label key={f.key} className={`text-sm ${f.span === 2 ? "sm:col-span-2" : ""}`}>
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">{f.label}</span>
|
||||||
|
<input
|
||||||
|
required={f.required}
|
||||||
|
value={values[f.key]}
|
||||||
|
onChange={(e) => setValues((v) => ({ ...v, [f.key]: e.target.value }))}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{error && <p className="sm:col-span-2 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 sm:col-span-2">
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||||
|
{loading ? "Сохраняем…" : "Сохранить"}
|
||||||
|
</button>
|
||||||
|
{saved && <span className="text-sm text-text-muted">Сохранено</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { getCompanySettings } from "@/lib/company/settings";
|
||||||
|
import { CompanySettingsForm } from "./company-settings-form";
|
||||||
|
|
||||||
|
export default async function CompanySettingsPage() {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session) {
|
||||||
|
redirect("/orders");
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await getCompanySettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Реквизиты компании</h1>
|
||||||
|
<p className="mb-6 text-sm text-text-muted">
|
||||||
|
Собственные реквизиты компании — подставляются в договоры, приложения и доверенности как фиксированная
|
||||||
|
сторона (в отличие от реквизитов контрагента, которые берутся из карточки клиента/перевозчика).
|
||||||
|
</p>
|
||||||
|
<CompanySettingsForm initial={settings} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Plus, Trash2, Pencil, Check, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface Contact {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
position: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
email: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
name: string;
|
||||||
|
position: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyForm(): FormState {
|
||||||
|
return { name: "", position: "", phone: "", email: "", notes: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactsList({ counterpartyId, initialContacts }: { counterpartyId: string; initialContacts: Contact[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [contacts, setContacts] = useState(initialContacts);
|
||||||
|
const [form, setForm] = useState<FormState>(emptyForm());
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState<FormState>(emptyForm());
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const res = await fetch(`/api/counterparties/${counterpartyId}/contacts`);
|
||||||
|
if (res.ok) setContacts(await res.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.name) return;
|
||||||
|
setSaving(true);
|
||||||
|
await fetch(`/api/counterparties/${counterpartyId}/contacts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
setForm(emptyForm());
|
||||||
|
setShowForm(false);
|
||||||
|
await refresh();
|
||||||
|
setSaving(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(c: Contact) {
|
||||||
|
setEditingId(c.id);
|
||||||
|
setEditForm({ name: c.name, position: c.position ?? "", phone: c.phone ?? "", email: c.email ?? "", notes: c.notes ?? "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(id: string) {
|
||||||
|
setSaving(true);
|
||||||
|
await fetch(`/api/counterparties/${counterpartyId}/contacts/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(editForm),
|
||||||
|
});
|
||||||
|
setEditingId(null);
|
||||||
|
await refresh();
|
||||||
|
setSaving(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
if (!confirm("Удалить контакт?")) return;
|
||||||
|
await fetch(`/api/counterparties/${counterpartyId}/contacts/${id}`, { method: "DELETE" });
|
||||||
|
await refresh();
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-text-muted">Дополнительные контактные лица</h2>
|
||||||
|
<button onClick={() => setShowForm((v) => !v)} className="btn btn-ghost">
|
||||||
|
<Plus size={14} />
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={handleCreate} className="mb-3 flex flex-wrap items-end gap-3 rounded-md border border-border p-3">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Имя</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
className="rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Должность</span>
|
||||||
|
<input
|
||||||
|
value={form.position}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, position: e.target.value }))}
|
||||||
|
className="rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Телефон</span>
|
||||||
|
<input
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
|
||||||
|
className="rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Email</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||||
|
className="rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={saving} className="btn btn-primary">
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{contacts.map((c) =>
|
||||||
|
editingId === c.id ? (
|
||||||
|
<div key={c.id} className="flex flex-wrap items-end gap-3 rounded-md border border-border bg-surface-hover p-3">
|
||||||
|
<input
|
||||||
|
value={editForm.name}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
placeholder="Имя"
|
||||||
|
className="rounded-md border border-border bg-surface px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={editForm.position}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, position: e.target.value }))}
|
||||||
|
placeholder="Должность"
|
||||||
|
className="rounded-md border border-border bg-surface px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={editForm.phone}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, phone: e.target.value }))}
|
||||||
|
placeholder="Телефон"
|
||||||
|
className="rounded-md border border-border bg-surface px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={editForm.email}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, email: e.target.value }))}
|
||||||
|
placeholder="Email"
|
||||||
|
className="rounded-md border border-border bg-surface px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={() => saveEdit(c.id)} disabled={saving} className="btn btn-ghost px-2 text-success">
|
||||||
|
<Check size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditingId(null)} className="btn btn-ghost px-2">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key={c.id} className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border px-3 py-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{c.name}</span>
|
||||||
|
{c.position && <span className="text-text-muted"> · {c.position}</span>}
|
||||||
|
<div className="text-xs text-text-faint">{[c.phone, c.email].filter(Boolean).join(" · ") || "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={() => startEdit(c)} className="btn btn-ghost px-2">
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(c.id)} className="btn btn-ghost px-2 text-danger">
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{contacts.length === 0 && <p className="text-sm text-text-faint">Пока нет дополнительных контактов.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { InferSelectModel } from "drizzle-orm";
|
||||||
|
import type { counterparties } from "@/lib/db/schema";
|
||||||
|
|
||||||
|
type Counterparty = InferSelectModel<typeof counterparties>;
|
||||||
|
|
||||||
|
function toDateInputValue(date: Date | null): string {
|
||||||
|
if (!date) return "";
|
||||||
|
const d = new Date(date);
|
||||||
|
const yyyy = d.getFullYear();
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${yyyy}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXT_FIELDS: { key: keyof Counterparty; label: string; span?: 1 | 2 }[] = [
|
||||||
|
{ key: "name", label: "Название", span: 2 },
|
||||||
|
{ key: "inn", label: "ИНН" },
|
||||||
|
{ key: "kpp", label: "КПП" },
|
||||||
|
{ key: "ogrn", label: "ОГРН" },
|
||||||
|
{ key: "legalAddress", label: "Юридический адрес", span: 2 },
|
||||||
|
{ key: "postalAddress", label: "Почтовый адрес (если отличается)", span: 2 },
|
||||||
|
{ key: "bankName", label: "Банк", span: 2 },
|
||||||
|
{ key: "bankAccount", label: "Расчётный счёт" },
|
||||||
|
{ key: "bankBik", label: "БИК" },
|
||||||
|
{ key: "bankCorrAccount", label: "Корр. счёт" },
|
||||||
|
{ key: "signatoryName", label: "Подписант (ФИО, именительный падеж)", span: 2 },
|
||||||
|
{ key: "signatoryPosition", label: "Должность подписанта" },
|
||||||
|
{ key: "signatoryBasis", label: "Основание полномочий" },
|
||||||
|
{ key: "contactName", label: "Контактное лицо" },
|
||||||
|
{ key: "contactPhone", label: "Телефон" },
|
||||||
|
{ key: "contactEmail", label: "Email" },
|
||||||
|
{ key: "notes", label: "Заметки", span: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CounterpartyEditForm({ counterparty }: { counterparty: Counterparty }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [values, setValues] = useState<Record<string, string>>(() => {
|
||||||
|
const v: Record<string, string> = {};
|
||||||
|
for (const f of TEXT_FIELDS) v[f.key as string] = (counterparty[f.key] as string) ?? "";
|
||||||
|
return v;
|
||||||
|
});
|
||||||
|
const [kind, setKind] = useState(counterparty.kind);
|
||||||
|
const [edoType, setEdoType] = useState(counterparty.edoType);
|
||||||
|
const [contractDate, setContractDate] = useState(toDateInputValue(counterparty.contractDate));
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSaved(false);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/counterparties/${counterparty.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...values,
|
||||||
|
kind,
|
||||||
|
edoType,
|
||||||
|
contractDate: contractDate || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось сохранить");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaved(true);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="card grid grid-cols-1 gap-3 p-4 sm:grid-cols-2">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Тип</span>
|
||||||
|
<select
|
||||||
|
value={kind}
|
||||||
|
onChange={(e) => setKind(e.target.value as Counterparty["kind"])}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="client">Клиент</option>
|
||||||
|
<option value="carrier">Перевозчик</option>
|
||||||
|
<option value="both">Клиент и перевозчик</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">ЭДО</span>
|
||||||
|
<select
|
||||||
|
value={edoType}
|
||||||
|
onChange={(e) => setEdoType(e.target.value as Counterparty["edoType"])}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="email">Почта</option>
|
||||||
|
<option value="diadoc">Диадок</option>
|
||||||
|
<option value="sbis">СБИС</option>
|
||||||
|
<option value="none">Нет</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{TEXT_FIELDS.map((f) => (
|
||||||
|
<label key={f.key as string} className={`text-sm ${f.span === 2 ? "sm:col-span-2" : ""}`}>
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">{f.label}</span>
|
||||||
|
<input
|
||||||
|
required={f.key === "name"}
|
||||||
|
value={values[f.key as string]}
|
||||||
|
onChange={(e) => setValues((v) => ({ ...v, [f.key as string]: e.target.value }))}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm sm:col-span-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Договор №</span>
|
||||||
|
<input
|
||||||
|
value={values.contractNumber}
|
||||||
|
onChange={(e) => setValues((v) => ({ ...v, contractNumber: e.target.value }))}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Дата договора</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={contractDate}
|
||||||
|
onChange={(e) => setContractDate(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text sm:col-span-2">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 sm:col-span-2">
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||||
|
{loading ? "Сохраняем…" : "Сохранить"}
|
||||||
|
</button>
|
||||||
|
{saved && <span className="text-sm text-text-muted">Сохранено</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { getCounterparty } from "@/lib/counterparties/service";
|
||||||
|
import { CounterpartyEditForm } from "./edit-form";
|
||||||
|
import { ContactsList } from "./contacts-list";
|
||||||
|
|
||||||
|
export default async function CounterpartyEditPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session) {
|
||||||
|
redirect("/orders");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const row = await getCounterparty(id);
|
||||||
|
if (!row) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-5">
|
||||||
|
<h1 className="font-display text-xl font-bold tracking-tight">{row.name}</h1>
|
||||||
|
<p className="text-sm text-text-muted">Реквизиты контрагента для генерации документов</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CounterpartyEditForm counterparty={row} />
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<ContactsList counterpartyId={row.id} initialContacts={row.contacts} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: "client" | "carrier" | "both";
|
||||||
|
inn: string | null;
|
||||||
|
contractNumber: string | null;
|
||||||
|
contactName: string | null;
|
||||||
|
contactPhone: string | null;
|
||||||
|
edoType: "none" | "email" | "diadoc" | "sbis";
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_LABEL: Record<Row["kind"], string> = {
|
||||||
|
client: "Клиент",
|
||||||
|
carrier: "Перевозчик",
|
||||||
|
both: "Клиент и перевозчик",
|
||||||
|
};
|
||||||
|
|
||||||
|
const EDO_LABEL: Record<Row["edoType"], string> = {
|
||||||
|
none: "Нет",
|
||||||
|
email: "Почта",
|
||||||
|
diadoc: "Диадок",
|
||||||
|
sbis: "СБИС",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Tab = "all" | "client" | "carrier";
|
||||||
|
|
||||||
|
const TABS: { key: Tab; label: string }[] = [
|
||||||
|
{ key: "all", label: "Все" },
|
||||||
|
{ key: "client", label: "Клиенты" },
|
||||||
|
{ key: "carrier", label: "Исполнители" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CounterpartiesTable({ rows }: { rows: Row[] }) {
|
||||||
|
const [tab, setTab] = useState<Tab>("all");
|
||||||
|
|
||||||
|
const visible = rows.filter((r) => {
|
||||||
|
if (tab === "all") return true;
|
||||||
|
if (tab === "client") return r.kind === "client" || r.kind === "both";
|
||||||
|
return r.kind === "carrier" || r.kind === "both";
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex gap-1">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className={`rounded-md px-3 py-1.5 text-sm font-medium ${
|
||||||
|
tab === t.key ? "bg-accent-soft text-accent-soft-text" : "text-text-muted hover:bg-surface-hover"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left text-text-muted">
|
||||||
|
<th className="px-4 py-3 font-medium">Название</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Тип</th>
|
||||||
|
<th className="px-4 py-3 font-medium">ИНН</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Договор</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Контакт</th>
|
||||||
|
<th className="px-4 py-3 font-medium">ЭДО</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visible.map((c) => (
|
||||||
|
<tr key={c.id} className="border-b border-border last:border-0 hover:bg-surface-hover">
|
||||||
|
<td className="px-4 py-3 font-medium">
|
||||||
|
<Link href={`/settings/counterparties/${c.id}`} className="hover:text-accent hover:underline">
|
||||||
|
{c.name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">{KIND_LABEL[c.kind]}</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">{c.inn || "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">{c.contractNumber || "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">
|
||||||
|
{c.contactName || "—"}
|
||||||
|
{c.contactPhone ? `, ${c.contactPhone}` : ""}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">{EDO_LABEL[c.edoType]}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{visible.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-8 text-center text-text-faint">
|
||||||
|
{tab === "all" ? "Пока нет контрагентов." : "Нет контрагентов в этой категории."}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
export function NewCounterpartyModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [kind, setKind] = useState<"client" | "carrier" | "both">("client");
|
||||||
|
const [inn, setInn] = useState("");
|
||||||
|
const [contractNumber, setContractNumber] = useState("");
|
||||||
|
const [contractDate, setContractDate] = useState("");
|
||||||
|
const [postalAddress, setPostalAddress] = useState("");
|
||||||
|
const [contactName, setContactName] = useState("");
|
||||||
|
const [contactPhone, setContactPhone] = useState("");
|
||||||
|
const [contactEmail, setContactEmail] = useState("");
|
||||||
|
const [edoType, setEdoType] = useState<"none" | "diadoc" | "sbis" | "email">("email");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setName("");
|
||||||
|
setKind("client");
|
||||||
|
setInn("");
|
||||||
|
setContractNumber("");
|
||||||
|
setContractDate("");
|
||||||
|
setPostalAddress("");
|
||||||
|
setContactName("");
|
||||||
|
setContactPhone("");
|
||||||
|
setContactEmail("");
|
||||||
|
setEdoType("email");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch("/api/counterparties", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
kind,
|
||||||
|
inn,
|
||||||
|
contractNumber,
|
||||||
|
contractDate: contractDate || undefined,
|
||||||
|
postalAddress,
|
||||||
|
contactName,
|
||||||
|
contactPhone,
|
||||||
|
contactEmail,
|
||||||
|
edoType,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось создать контрагента");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.form
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
|
className="card w-full max-w-md p-5"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold">Новый контрагент</h2>
|
||||||
|
<button type="button" onClick={onClose} className="text-text-muted hover:text-text">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Название</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mb-3 grid grid-cols-2 gap-3">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Тип</span>
|
||||||
|
<select
|
||||||
|
value={kind}
|
||||||
|
onChange={(e) => setKind(e.target.value as typeof kind)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="client">Клиент</option>
|
||||||
|
<option value="carrier">Перевозчик</option>
|
||||||
|
<option value="both">Клиент и перевозчик</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">ИНН</span>
|
||||||
|
<input
|
||||||
|
value={inn}
|
||||||
|
onChange={(e) => setInn(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 grid grid-cols-2 gap-3">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Договор №</span>
|
||||||
|
<input
|
||||||
|
value={contractNumber}
|
||||||
|
onChange={(e) => setContractNumber(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Дата договора</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={contractDate}
|
||||||
|
onChange={(e) => setContractDate(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Почтовый адрес (если отличается от юридического)</span>
|
||||||
|
<input
|
||||||
|
value={postalAddress}
|
||||||
|
onChange={(e) => setPostalAddress(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mb-3 grid grid-cols-2 gap-3">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Контактное лицо</span>
|
||||||
|
<input
|
||||||
|
value={contactName}
|
||||||
|
onChange={(e) => setContactName(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Телефон</span>
|
||||||
|
<input
|
||||||
|
value={contactPhone}
|
||||||
|
onChange={(e) => setContactPhone(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 grid grid-cols-2 gap-3">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Email</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={contactEmail}
|
||||||
|
onChange={(e) => setContactEmail(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">ЭДО</span>
|
||||||
|
<select
|
||||||
|
value={edoType}
|
||||||
|
onChange={(e) => setEdoType(e.target.value as typeof edoType)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="email">Почта</option>
|
||||||
|
<option value="diadoc">Диадок</option>
|
||||||
|
<option value="sbis">СБИС</option>
|
||||||
|
<option value="none">Нет</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
|
||||||
|
{loading ? "Создаём…" : "Создать"}
|
||||||
|
</button>
|
||||||
|
</motion.form>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { listCounterparties } from "@/lib/counterparties/service";
|
||||||
|
import { CounterpartiesToolbar } from "./toolbar";
|
||||||
|
import { CounterpartiesTable } from "./counterparties-table";
|
||||||
|
|
||||||
|
export default async function CounterpartiesPage() {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session) {
|
||||||
|
redirect("/orders");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await listCounterparties();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<h1 className="font-display text-xl font-bold tracking-tight">Контрагенты</h1>
|
||||||
|
<CounterpartiesToolbar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CounterpartiesTable rows={rows} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { NewCounterpartyModal } from "./new-counterparty-modal";
|
||||||
|
|
||||||
|
export function CounterpartiesToolbar() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-primary" onClick={() => setOpen(true)}>
|
||||||
|
<Plus size={16} />
|
||||||
|
Новый контрагент
|
||||||
|
</button>
|
||||||
|
<NewCounterpartyModal open={open} onClose={() => setOpen(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface Status {
|
||||||
|
configured: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
user: string | null;
|
||||||
|
imapHost: string | null;
|
||||||
|
verifiedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MailboxForm({ status }: { status: Status }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [imapHost, setImapHost] = useState(status.imapHost ?? "");
|
||||||
|
const [imapPort, setImapPort] = useState("993");
|
||||||
|
const [user, setUser] = useState(status.user ?? "");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [allowInsecureTls, setAllowInsecureTls] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch("/api/settings/mailbox", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ imapHost, imapPort: Number(imapPort), user, password, allowInsecureTls }),
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось подключиться к почтовому ящику");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPassword("");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDisable() {
|
||||||
|
setLoading(true);
|
||||||
|
await fetch("/api/settings/mailbox", { method: "DELETE" });
|
||||||
|
setLoading(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-muted">Почтовый ящик для запросов (IMAP)</h2>
|
||||||
|
|
||||||
|
<div className="mb-4 text-sm">
|
||||||
|
{status.configured ? (
|
||||||
|
<p className="text-text-muted">
|
||||||
|
{status.enabled ? "Активен" : "Отключен"}: {status.user} @ {status.imapHost}
|
||||||
|
{status.verifiedAt && ` — проверен ${new Date(status.verifiedAt).toLocaleString("ru-RU")}`}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-text-faint">Ещё не настроен.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">IMAP-сервер</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={imapHost}
|
||||||
|
onChange={(e) => setImapHost(e.target.value)}
|
||||||
|
placeholder="imap.yandex.ru"
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Порт</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
value={imapPort}
|
||||||
|
onChange={(e) => setImapPort(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Логин</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={user}
|
||||||
|
onChange={(e) => setUser(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm sm:col-span-2">
|
||||||
|
<input type="checkbox" checked={allowInsecureTls} onChange={(e) => setAllowInsecureTls(e.target.checked)} />
|
||||||
|
<span className="text-text-muted">Разрешить недоверенный TLS-сертификат</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="sm:col-span-2 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex gap-2 sm:col-span-2">
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||||
|
{loading ? "Проверяем…" : "Проверить и сохранить"}
|
||||||
|
</button>
|
||||||
|
{status.configured && status.enabled && (
|
||||||
|
<button type="button" onClick={handleDisable} disabled={loading} className="btn btn-ghost">
|
||||||
|
Отключить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { getMailboxStatus } from "@/lib/mail/config";
|
||||||
|
import { MailboxForm } from "./mailbox-form";
|
||||||
|
|
||||||
|
export default async function EmailSettingsPage() {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session) redirect("/orders");
|
||||||
|
|
||||||
|
const status = await getMailboxStatus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Почта</h1>
|
||||||
|
<p className="mb-5 text-sm text-text-muted">
|
||||||
|
Входящие письма распознаются через Claude в черновики запросов — менеджер подтверждает их на странице{" "}
|
||||||
|
«Запросы» перед созданием настоящего запроса.
|
||||||
|
</p>
|
||||||
|
<MailboxForm status={status} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-5 flex gap-1 border-b border-border">
|
||||||
|
<Link href="/settings/company" className="px-3 py-2 text-sm font-medium text-text-muted hover:text-text">
|
||||||
|
Компания
|
||||||
|
</Link>
|
||||||
|
<Link href="/settings/counterparties" className="px-3 py-2 text-sm font-medium text-text-muted hover:text-text">
|
||||||
|
Контрагенты
|
||||||
|
</Link>
|
||||||
|
<Link href="/settings/users" className="px-3 py-2 text-sm font-medium text-text-muted hover:text-text">
|
||||||
|
Пользователи
|
||||||
|
</Link>
|
||||||
|
<Link href="/settings/templates" className="px-3 py-2 text-sm font-medium text-text-muted hover:text-text">
|
||||||
|
Шаблоны
|
||||||
|
</Link>
|
||||||
|
<Link href="/settings/email" className="px-3 py-2 text-sm font-medium text-text-muted hover:text-text">
|
||||||
|
Почта
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { listTemplates } from "@/lib/documents/templates";
|
||||||
|
import { UploadTemplateForm } from "./upload-form";
|
||||||
|
|
||||||
|
const KIND_LABEL: Record<string, string> = {
|
||||||
|
power_of_attorney: "Доверенность",
|
||||||
|
forwarder_receipt: "Экспедиторская расписка",
|
||||||
|
contract_teo_forwarder: "Договор ТЭУ (наша компания — Экспедитор)",
|
||||||
|
contract_teo_customer: "Договор ТЭО (наша компания — Заказчик)",
|
||||||
|
appendix_order_forwarder: "Приложение — Поручение + Расписка (наша компания — Экспедитор)",
|
||||||
|
appendix_order_customer: "Приложение — Поручение + Расписка (наша компания — Заказчик)",
|
||||||
|
order_agreement_road_forwarder: "Договор-заявка на автоперевозку (наша компания — Экспедитор)",
|
||||||
|
order_agreement_teo_customer: "Договор-заявка на ТЭО (наша компания — Заказчик)",
|
||||||
|
other: "Другое",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function TemplatesPage() {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session) redirect("/orders");
|
||||||
|
|
||||||
|
const templates = await listTemplates();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<h1 className="font-display text-xl font-bold tracking-tight">Шаблоны документов</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-4 text-sm text-text-muted">
|
||||||
|
Стартовые шаблоны загружаются в Настройки → Шаблоны. Загружая свой .docx взамен, используйте теги: заявка —{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">{"{order_number}"}</code>,{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">{"{order_date}"}</code>,{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">{"{contract_date}"}</code>,{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">{"{cargo_description}"}</code>,{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">{"{route_from}"}</code>,{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">{"{route_to}"}</code>,{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">{"{flight_number}"}</code>,{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">{"{manager_name}"}</code>,{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">{"{load_date}"}</code>,{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">{"{unload_plan_date}"}</code>; доверенность —{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">{"{poa_number}"}</code>,{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">{"{poa_date}"}</code>,{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">{"{poa_valid_until}"}</code>,{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">{"{poa_driver_name}"}</code>,{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">{"{poa_driver_passport}"}</code>,{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">{"{poa_vehicle_model}"}</code>,{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">{"{poa_vehicle_plate}"}</code>; реквизиты сторон — с
|
||||||
|
префиксом <code className="rounded bg-surface-hover px-1">{"company_*"}</code> (наша компания, из Настройки → Компания)
|
||||||
|
и <code className="rounded bg-surface-hover px-1">{"customer_*"}</code> (контрагент заявки), например{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">{"{company_name}"}</code>,{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">{"{customer_signatory_short}"}</code>. Полный список — в{" "}
|
||||||
|
<code className="rounded bg-surface-hover px-1">buildTemplateData</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="card mb-5 overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left text-text-muted">
|
||||||
|
<th className="px-4 py-3 font-medium">Название</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Тип</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{templates.map((t) => (
|
||||||
|
<tr key={t.id} className="border-b border-border last:border-0">
|
||||||
|
<td className="px-4 py-3">{t.name}</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">{KIND_LABEL[t.kind]}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UploadTemplateForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
const KIND_OPTIONS = [
|
||||||
|
{ value: "power_of_attorney", label: "Доверенность" },
|
||||||
|
{ value: "forwarder_receipt", label: "Экспедиторская расписка" },
|
||||||
|
{ value: "contract_teo_forwarder", label: "Договор ТЭУ (наша компания — Экспедитор)" },
|
||||||
|
{ value: "contract_teo_customer", label: "Договор ТЭО (наша компания — Заказчик)" },
|
||||||
|
{ value: "appendix_order_forwarder", label: "Приложение — Поручение + Расписка (наша компания — Экспедитор)" },
|
||||||
|
{ value: "appendix_order_customer", label: "Приложение — Поручение + Расписка (наша компания — Заказчик)" },
|
||||||
|
{ value: "order_agreement_road_forwarder", label: "Договор-заявка на автоперевозку (наша компания — Экспедитор)" },
|
||||||
|
{ value: "order_agreement_teo_customer", label: "Договор-заявка на ТЭО (наша компания — Заказчик)" },
|
||||||
|
{ value: "other", label: "Другое" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function UploadTemplateForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [kind, setKind] = useState("power_of_attorney");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!file) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("name", name);
|
||||||
|
form.set("kind", kind);
|
||||||
|
form.set("file", file);
|
||||||
|
|
||||||
|
const res = await fetch("/api/templates", { method: "POST", body: form });
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось загрузить шаблон");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setName("");
|
||||||
|
setFile(null);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="card flex flex-wrap items-end gap-2 p-4">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Название</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Тип</span>
|
||||||
|
<select
|
||||||
|
value={kind}
|
||||||
|
onChange={(e) => setKind(e.target.value)}
|
||||||
|
className="rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{KIND_OPTIONS.map((k) => (
|
||||||
|
<option key={k.value} value={k.value}>
|
||||||
|
{k.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Файл .docx</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
required
|
||||||
|
accept=".docx"
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||||
|
className="rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||||
|
{loading ? "Загружаем…" : "Загрузить"}
|
||||||
|
</button>
|
||||||
|
{error && <p className="w-full text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
export function NewUserModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [role, setRole] = useState<"admin" | "manager" | "accountant">("manager");
|
||||||
|
const [managerNumber, setManagerNumber] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setEmail("");
|
||||||
|
setPassword("");
|
||||||
|
setName("");
|
||||||
|
setRole("manager");
|
||||||
|
setManagerNumber("");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch("/api/users", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
managerNumber: role === "manager" && managerNumber ? Number(managerNumber) : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось создать пользователя");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.form
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
|
className="card w-full max-w-md p-5"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold">Новый пользователь</h2>
|
||||||
|
<button type="button" onClick={onClose} className="text-text-muted hover:text-text">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Имя</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Email</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mb-4 grid grid-cols-2 gap-3">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Роль</span>
|
||||||
|
<select
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => setRole(e.target.value as typeof role)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="manager">Менеджер</option>
|
||||||
|
<option value="accountant">Бухгалтер</option>
|
||||||
|
<option value="admin">Администратор</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{role === "manager" && (
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Номер менеджера</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
value={managerNumber}
|
||||||
|
onChange={(e) => setManagerNumber(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
|
||||||
|
{loading ? "Создаём…" : "Создать"}
|
||||||
|
</button>
|
||||||
|
</motion.form>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { listUsers } from "@/lib/users/service";
|
||||||
|
import { UsersToolbar } from "./toolbar";
|
||||||
|
|
||||||
|
const ROLE_LABEL: Record<string, string> = {
|
||||||
|
admin: "Администратор",
|
||||||
|
manager: "Менеджер",
|
||||||
|
accountant: "Бухгалтер",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function UsersPage() {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session) {
|
||||||
|
redirect("/orders");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await listUsers();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<h1 className="font-display text-xl font-bold tracking-tight">Пользователи</h1>
|
||||||
|
<UsersToolbar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left text-text-muted">
|
||||||
|
<th className="px-4 py-3 font-medium">Имя</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Email</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Роль</th>
|
||||||
|
<th className="px-4 py-3 font-medium">№ менеджера</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((u) => (
|
||||||
|
<tr key={u.id} className="border-b border-border last:border-0">
|
||||||
|
<td className="px-4 py-3 font-medium">{u.name}</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">{u.email}</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">{ROLE_LABEL[u.role]}</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">{u.managerNumber ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { NewUserModal } from "./new-user-modal";
|
||||||
|
|
||||||
|
export function UsersToolbar() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-primary" onClick={() => setOpen(true)}>
|
||||||
|
<Plus size={16} />
|
||||||
|
Новый пользователь
|
||||||
|
</button>
|
||||||
|
<NewUserModal open={open} onClose={() => setOpen(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { listTasksForUser } from "@/lib/tasks/service";
|
||||||
|
import { listUsers } from "@/lib/users/service";
|
||||||
|
import { TasksBoard } from "./tasks-board";
|
||||||
|
|
||||||
|
export default async function TasksPage() {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const [tasksRows, users] = await Promise.all([listTasksForUser(session.user.id), listUsers()]);
|
||||||
|
|
||||||
|
const tasks = tasksRows.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
description: t.description,
|
||||||
|
status: t.status,
|
||||||
|
dueAt: t.dueAt ? t.dueAt.getTime() : null,
|
||||||
|
conversationId: t.conversationId,
|
||||||
|
assignee: { id: t.assignee.id, name: t.assignee.name },
|
||||||
|
creator: { id: t.creator.id, name: t.creator.name },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const people = users.map((u) => ({ id: u.id, name: u.name }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-5">
|
||||||
|
<h1 className="font-display text-xl font-bold tracking-tight">Задачи</h1>
|
||||||
|
<p className="text-sm text-text-muted">Постановка задач и напоминания — вам и от вас</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TasksBoard currentUserId={session.user.id} initialTasks={tasks} people={people} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Plus, Check, Trash2, RotateCcw, Clock } from "lucide-react";
|
||||||
|
|
||||||
|
interface Person {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
status: "open" | "done";
|
||||||
|
dueAt: number | null;
|
||||||
|
conversationId: string | null;
|
||||||
|
assignee: Person;
|
||||||
|
creator: Person;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDue(ms: number): string {
|
||||||
|
return new Date(ms).toLocaleString("ru-RU", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverdue(task: Task): boolean {
|
||||||
|
return task.status === "open" && task.dueAt !== null && task.dueAt < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyForm(currentUserId: string) {
|
||||||
|
return { title: "", description: "", assigneeId: currentUserId, dueAt: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TasksBoard({
|
||||||
|
currentUserId,
|
||||||
|
initialTasks,
|
||||||
|
people,
|
||||||
|
}: {
|
||||||
|
currentUserId: string;
|
||||||
|
initialTasks: Task[];
|
||||||
|
people: Person[];
|
||||||
|
}) {
|
||||||
|
const [tasks, setTasks] = useState(initialTasks);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [form, setForm] = useState(emptyForm(currentUserId));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const res = await fetch("/api/tasks");
|
||||||
|
if (res.ok) setTasks(await res.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.title.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch("/api/tasks", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: form.title.trim(),
|
||||||
|
description: form.description.trim() || undefined,
|
||||||
|
assigneeId: form.assigneeId,
|
||||||
|
dueAt: form.dueAt || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
setError("Не удалось создать задачу");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setForm(emptyForm(currentUserId));
|
||||||
|
setShowForm(false);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleStatus(task: Task) {
|
||||||
|
const nextStatus = task.status === "open" ? "done" : "open";
|
||||||
|
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, status: nextStatus } : t)));
|
||||||
|
await fetch(`/api/tasks/${task.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status: nextStatus }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
if (!confirm("Удалить задачу?")) return;
|
||||||
|
setTasks((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
await fetch(`/api/tasks/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = tasks.filter((t) => t.status === "open");
|
||||||
|
const done = tasks.filter((t) => t.status === "done");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-text-muted">Новая задача</h2>
|
||||||
|
<button onClick={() => setShowForm((v) => !v)} className="btn btn-ghost">
|
||||||
|
<Plus size={14} />
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={handleCreate} className="flex flex-col gap-3">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Заголовок</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Описание</span>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||||
|
rows={2}
|
||||||
|
className="w-full resize-none rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Исполнитель</span>
|
||||||
|
<select
|
||||||
|
value={form.assigneeId}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, assigneeId: e.target.value }))}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{people.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.id === currentUserId ? `${p.name} (я)` : p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Напомнить (дата и время)</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.dueAt}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, dueAt: e.target.value }))}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
<button type="submit" disabled={saving} className="btn btn-primary self-start">
|
||||||
|
Создать
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-sm font-semibold text-text-muted">Открытые ({open.length})</h2>
|
||||||
|
{open.map((task) => (
|
||||||
|
<TaskRow key={task.id} task={task} currentUserId={currentUserId} onToggle={toggleStatus} onDelete={handleDelete} />
|
||||||
|
))}
|
||||||
|
{open.length === 0 && <p className="text-sm text-text-faint">Нет открытых задач.</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{done.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-sm font-semibold text-text-muted">Выполненные ({done.length})</h2>
|
||||||
|
{done.map((task) => (
|
||||||
|
<TaskRow key={task.id} task={task} currentUserId={currentUserId} onToggle={toggleStatus} onDelete={handleDelete} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskRow({
|
||||||
|
task,
|
||||||
|
currentUserId,
|
||||||
|
onToggle,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
task: Task;
|
||||||
|
currentUserId: string;
|
||||||
|
onToggle: (task: Task) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const overdue = isOverdue(task);
|
||||||
|
const relation =
|
||||||
|
task.assignee.id === currentUserId && task.creator.id !== currentUserId
|
||||||
|
? `от ${task.creator.name}`
|
||||||
|
: task.assignee.id !== currentUserId
|
||||||
|
? `для ${task.assignee.name}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`card flex items-start gap-3 p-3 ${task.status === "done" ? "opacity-60" : ""}`}>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle(task)}
|
||||||
|
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border ${
|
||||||
|
task.status === "done" ? "border-success bg-success text-white" : "border-border hover:border-accent"
|
||||||
|
}`}
|
||||||
|
title={task.status === "done" ? "Вернуть в открытые" : "Отметить выполненной"}
|
||||||
|
>
|
||||||
|
{task.status === "done" ? <Check size={12} /> : null}
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className={`text-sm font-medium ${task.status === "done" ? "line-through" : ""}`}>{task.title}</p>
|
||||||
|
{task.description && <p className="mt-0.5 text-xs text-text-muted">{task.description}</p>}
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-text-faint">
|
||||||
|
{relation && <span>{relation}</span>}
|
||||||
|
{task.dueAt !== null && (
|
||||||
|
<span className={`flex items-center gap-1 ${overdue ? "text-danger-soft-text" : ""}`}>
|
||||||
|
<Clock size={11} />
|
||||||
|
{formatDue(task.dueAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 gap-1">
|
||||||
|
{task.status === "done" && (
|
||||||
|
<button onClick={() => onToggle(task)} className="btn btn-ghost px-2" title="Вернуть в открытые">
|
||||||
|
<RotateCcw size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => onDelete(task.id)} className="btn btn-ghost px-2 text-danger" title="Удалить">
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLayoutEffect } from "react";
|
||||||
|
import { Sun, Moon } from "lucide-react";
|
||||||
|
|
||||||
|
function getEffectiveTheme(): "light" | "dark" {
|
||||||
|
const stored = document.documentElement.dataset.theme;
|
||||||
|
if (stored === "light" || stored === "dark") return stored;
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both icons always render; CSS (mirroring the same data-theme cascade the
|
||||||
|
// palette itself uses) decides which one shows — avoids a useState/useEffect
|
||||||
|
// pair just to reflect the DOM attribute the no-flash script already set.
|
||||||
|
export function ThemeToggle() {
|
||||||
|
// The <head> no-flash script sets data-theme before hydration, but
|
||||||
|
// something in this app's streaming SSR/hydration commits again shortly
|
||||||
|
// after first paint and strips it back off (confirmed: present at ~0ms,
|
||||||
|
// gone by ~50ms, and it doesn't come back on its own) — suppressHydration
|
||||||
|
// Warning on <html> only silences the mismatch warning for the *first*
|
||||||
|
// hydration diff, it doesn't stop a later commit from resetting an
|
||||||
|
// attribute React doesn't know about. Re-applying here, once this client
|
||||||
|
// component has mounted (i.e. after that reset has already happened),
|
||||||
|
// wins the race and makes it stick.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const stored = localStorage.getItem("theme");
|
||||||
|
if (stored === "light" || stored === "dark") {
|
||||||
|
document.documentElement.dataset.theme = stored;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
const next = getEffectiveTheme() === "dark" ? "light" : "dark";
|
||||||
|
document.documentElement.dataset.theme = next;
|
||||||
|
localStorage.setItem("theme", next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={toggle} className="btn btn-ghost theme-toggle-btn" title="Переключить тему" aria-label="Переключить тему">
|
||||||
|
<Sun size={15} className="theme-icon-sun" />
|
||||||
|
<Moon size={15} className="theme-icon-moon" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { listActiveTracking } from "@/lib/tracking/service";
|
||||||
|
import { TrackingTable } from "./tracking-table";
|
||||||
|
|
||||||
|
export default async function TrackingPage() {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
if (session.user.role === "accountant") redirect("/orders");
|
||||||
|
|
||||||
|
const rows = await listActiveTracking();
|
||||||
|
const visible = session.user.role === "manager" ? rows.filter((r) => r.order.managerId === session.user.id) : rows;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Слежение</h1>
|
||||||
|
<p className="mb-5 text-sm text-text-muted">
|
||||||
|
Единая лента по всем активным заявкам вместо трёх Excel-файлов. Выберите заявки галочками, чтобы выгрузить
|
||||||
|
Excel или составить письмо с актуальными перевозками.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<TrackingTable rows={visible} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export function TrackingRowForm({ orderId }: { orderId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [location, setLocation] = useState("");
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [plannedUnloadDate, setPlannedUnloadDate] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
await fetch(`/api/orders/${orderId}/tracking`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ location, note: note || undefined, plannedUnloadDate: plannedUnloadDate || undefined }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
setLocation("");
|
||||||
|
setNote("");
|
||||||
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button onClick={() => setOpen(true)} className="btn btn-ghost text-xs">
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
placeholder="Местоположение"
|
||||||
|
value={location}
|
||||||
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
|
className="w-32 rounded-md border border-border bg-surface px-2 py-1 text-xs outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="Примечание"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
className="w-32 rounded-md border border-border bg-surface px-2 py-1 text-xs outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={plannedUnloadDate}
|
||||||
|
onChange={(e) => setPlannedUnloadDate(e.target.value)}
|
||||||
|
className="rounded-md border border-border bg-surface px-2 py-1 text-xs outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary text-xs">
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Download, Mail } from "lucide-react";
|
||||||
|
import { TrackingRowForm } from "./tracking-row-form";
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
order: {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
routeFrom: string;
|
||||||
|
routeTo: string;
|
||||||
|
customer: { name: string; contactEmail: string | null } | null;
|
||||||
|
};
|
||||||
|
latest: { location: string; plannedUnloadDate: string | number | Date | null; createdAt: string | number | Date } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrackingTable({ rows }: { rows: Row[] }) {
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
|
function toggle(id: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
setSelected((prev) => (prev.size === rows.length ? new Set() : new Set(rows.map((r) => r.order.id))));
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedRows = rows.filter((r) => selected.has(r.order.id));
|
||||||
|
|
||||||
|
async function exportExcel() {
|
||||||
|
setExporting(true);
|
||||||
|
const res = await fetch("/api/tracking/export", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids: [...selected] }),
|
||||||
|
});
|
||||||
|
setExporting(false);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "Слежение.xlsx";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeEmail() {
|
||||||
|
const distinctEmails = [...new Set(selectedRows.map((r) => r.order.customer?.contactEmail).filter((e): e is string => Boolean(e)))];
|
||||||
|
const to = distinctEmails.length === 1 ? distinctEmails[0] : "";
|
||||||
|
|
||||||
|
const lines = selectedRows.map((r) => {
|
||||||
|
const loc = r.latest?.location ?? "нет данных";
|
||||||
|
const plan = r.latest?.plannedUnloadDate ? new Date(r.latest.plannedUnloadDate).toLocaleDateString("ru-RU") : "не указан";
|
||||||
|
return `${r.order.number} (${r.order.routeFrom} -> ${r.order.routeTo}): ${loc}, план выгрузки ${plan}`;
|
||||||
|
});
|
||||||
|
const body = `Актуальный статус перевозок:\n\n${lines.join("\n")}`;
|
||||||
|
const subject = "Статус перевозок";
|
||||||
|
|
||||||
|
const mailto = `mailto:${encodeURIComponent(to)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
||||||
|
window.location.href = mailto;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card overflow-x-auto">
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<div className="flex items-center justify-between border-b border-border bg-accent-soft px-4 py-2 text-sm text-accent-soft-text">
|
||||||
|
<span>Выбрано: {selected.size}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={exportExcel} disabled={exporting} className="btn btn-primary">
|
||||||
|
<Download size={14} />
|
||||||
|
{exporting ? "Готовим…" : "Скачать Excel"}
|
||||||
|
</button>
|
||||||
|
<button onClick={composeEmail} className="btn btn-ghost">
|
||||||
|
<Mail size={14} />
|
||||||
|
Составить письмо
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left text-text-muted">
|
||||||
|
<th className="w-8 px-4 py-3">
|
||||||
|
<input type="checkbox" checked={rows.length > 0 && selected.size === rows.length} onChange={toggleAll} />
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Заявка</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Клиент</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Маршрут</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Местоположение</th>
|
||||||
|
<th className="px-4 py-3 font-medium">План выгрузки</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Обновлено</th>
|
||||||
|
<th className="px-4 py-3 font-medium"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map(({ order, latest }) => (
|
||||||
|
<tr key={order.id} className="border-b border-border last:border-0">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<input type="checkbox" checked={selected.has(order.id)} onChange={() => toggle(order.id)} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link href={`/orders/${order.id}`} className="font-mono text-xs text-accent hover:underline">
|
||||||
|
{order.number}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{order.customer?.name}</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">
|
||||||
|
{order.routeFrom} → {order.routeTo}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{latest?.location ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted">
|
||||||
|
{latest?.plannedUnloadDate ? new Date(latest.plannedUnloadDate).toLocaleDateString("ru-RU") : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-faint">{latest ? new Date(latest.createdAt).toLocaleString("ru-RU") : "—"}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<TrackingRowForm orderId={order.id} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-8 text-center text-text-faint">
|
||||||
|
Нет активных заявок.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Truck, LogIn } from "lucide-react";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось войти");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push("/orders");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="flex min-h-screen items-center justify-center px-4"
|
||||||
|
style={{
|
||||||
|
background: "radial-gradient(ellipse 60% 50% at 50% -10%, var(--accent-soft), var(--bg) 70%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||||
|
className="card w-full max-w-sm p-8"
|
||||||
|
>
|
||||||
|
<div className="mb-6 flex items-center gap-2">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-accent text-white">
|
||||||
|
<Truck size={18} strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<span className="font-display text-lg font-bold tracking-tight">Thunderbird Portal</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="mb-1 font-display text-xl font-semibold">Вход</h1>
|
||||||
|
<p className="mb-6 text-sm text-text-muted">Запросы, заявки, слежение и финансы — в одном месте.</p>
|
||||||
|
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Email</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mb-5 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="mb-4 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
|
||||||
|
<LogIn size={16} />
|
||||||
|
{loading ? "Входим…" : "Войти"}
|
||||||
|
</button>
|
||||||
|
</motion.form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className="min-h-screen bg-bg">{children}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Truck, MapPin } from "lucide-react";
|
||||||
|
import { getOrderByPublicToken } from "@/lib/tracking/service";
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
new: "Принята в работу",
|
||||||
|
in_progress: "В пути",
|
||||||
|
done: "Выгружена",
|
||||||
|
closed: "Завершена",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ClientTrackingPage({ params }: { params: Promise<{ token: string }> }) {
|
||||||
|
const { token } = await params;
|
||||||
|
const order = await getOrderByPublicToken(token);
|
||||||
|
if (!order) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-2xl px-4 py-10">
|
||||||
|
<div className="mb-6 flex items-center gap-2">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-accent text-white">
|
||||||
|
<Truck size={18} strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<span className="font-display text-lg font-bold tracking-tight">Thunderbird Portal</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card mb-5 p-5">
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<h1 className="font-display text-lg font-semibold">Заявка {order.number}</h1>
|
||||||
|
<span className="badge badge-open">{STATUS_LABEL[order.status]}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
{order.routeFrom} → {order.routeTo}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-text-muted">Груз: {order.cargoDescription}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-muted">История слежения</h2>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{order.trackingUpdates.map((u) => (
|
||||||
|
<div key={u.id} className="card flex items-start gap-3 p-3">
|
||||||
|
<MapPin size={16} className="mt-0.5 shrink-0 text-accent" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{u.location}</p>
|
||||||
|
{u.note && <p className="text-sm text-text-muted">{u.note}</p>}
|
||||||
|
{u.plannedUnloadDate && (
|
||||||
|
<p className="text-xs text-text-faint">
|
||||||
|
План выгрузки: {new Date(u.plannedUnloadDate).toLocaleDateString("ru-RU")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-text-faint">{new Date(u.createdAt).toLocaleString("ru-RU")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{order.trackingUpdates.length === 0 && <p className="text-sm text-text-faint">Пока нет обновлений.</p>}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { getAttachment } from "@/lib/attachments/service";
|
||||||
|
import { getOrder } from "@/lib/orders/service";
|
||||||
|
import { canAccessOrder } from "@/lib/orders/visibility";
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const attachment = await getAttachment(id);
|
||||||
|
if (!attachment) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const order = await getOrder(attachment.orderId);
|
||||||
|
if (!order || !canAccessOrder(order, session.user)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = fs.readFileSync(attachment.storedPath);
|
||||||
|
return new NextResponse(new Uint8Array(file), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": attachment.mimeType,
|
||||||
|
"Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(attachment.fileName)}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { verifyPassword } from "@/lib/auth/password";
|
||||||
|
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple in-memory rate limit — good enough at MVP scale, resets on restart.
|
||||||
|
const attempts = new Map<string, { count: number; resetAt: number }>();
|
||||||
|
const MAX_ATTEMPTS = 10;
|
||||||
|
const WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
function isRateLimited(key: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = attempts.get(key);
|
||||||
|
if (!entry || entry.resetAt < now) {
|
||||||
|
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
entry.count += 1;
|
||||||
|
return entry.count > MAX_ATTEMPTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = loginSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Некорректные данные" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = parsed.data.email.toLowerCase().trim();
|
||||||
|
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
|
||||||
|
if (isRateLimited(`${ip}:${email}`)) {
|
||||||
|
return NextResponse.json({ error: "Слишком много попыток — попробуйте позже" }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: (u, { eq }) => eq(u.email, email),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Always run verifyPassword (even against a placeholder hash) so the
|
||||||
|
// response timing doesn't reveal whether the email exists.
|
||||||
|
const ok = await verifyPassword(
|
||||||
|
user?.passwordHash ??
|
||||||
|
"$argon2id$v=19$m=65536,t=3,p=4$00000000000000000000000000$0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
parsed.data.password,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user || !ok) {
|
||||||
|
return NextResponse.json({ error: "Неверный email или пароль" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await createSession(user.id);
|
||||||
|
await setSessionCookie(token);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, user: { id: user.id, name: user.name, role: user.role } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { destroySessionToken, getSessionToken, clearSessionCookie } from "@/lib/auth/session";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const token = await getSessionToken();
|
||||||
|
if (token) {
|
||||||
|
await destroySessionToken(token);
|
||||||
|
}
|
||||||
|
await clearSessionCookie();
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { getChatAttachment } from "@/lib/chat/attachments";
|
||||||
|
import { getConversationForUser } from "@/lib/chat/service";
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const attachment = await getChatAttachment(id);
|
||||||
|
if (!attachment) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const conv = await getConversationForUser(attachment.message.conversationId, session.user.id);
|
||||||
|
if (!conv) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
const file = fs.readFileSync(attachment.storedPath);
|
||||||
|
return new NextResponse(new Uint8Array(file), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": attachment.mimeType,
|
||||||
|
"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(attachment.fileName)}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { listMessages, sendMessage } from "@/lib/chat/service";
|
||||||
|
import { saveChatAttachment } from "@/lib/chat/attachments";
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const messages = await listMessages(id, session.user.id);
|
||||||
|
if (messages === null) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
return NextResponse.json(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Один эндпоинт на текст/вложение/голосовое — форма всегда multipart, поля опциональны в зависимости от того, что отправляет клиент. */
|
||||||
|
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const form = await request.formData().catch(() => null);
|
||||||
|
if (!form) return NextResponse.json({ error: "Некорректные данные" }, { status: 400 });
|
||||||
|
|
||||||
|
const body = form.get("body");
|
||||||
|
const replyToMessageId = form.get("replyToMessageId");
|
||||||
|
const file = form.get("file");
|
||||||
|
const kindRaw = form.get("kind");
|
||||||
|
|
||||||
|
const bodyText = typeof body === "string" && body.trim() ? body.trim() : undefined;
|
||||||
|
if (!bodyText && !(file instanceof File)) {
|
||||||
|
return NextResponse.json({ error: "Пустое сообщение" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (file instanceof File && file.size > MAX_ATTACHMENT_BYTES) {
|
||||||
|
return NextResponse.json({ error: "Файл слишком большой (максимум 25 МБ)" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let message;
|
||||||
|
try {
|
||||||
|
message = await sendMessage(id, session.user.id, {
|
||||||
|
body: bodyText,
|
||||||
|
replyToMessageId: typeof replyToMessageId === "string" && replyToMessageId ? replyToMessageId : undefined,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Не удалось отправить сообщение";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let attachment = null;
|
||||||
|
if (file instanceof File) {
|
||||||
|
const kind = kindRaw === "voice" ? "voice" : file.type.startsWith("image/") ? "image" : "file";
|
||||||
|
const durationRaw = form.get("durationSeconds");
|
||||||
|
const content = Buffer.from(await file.arrayBuffer());
|
||||||
|
attachment = await saveChatAttachment({
|
||||||
|
messageId: message.id,
|
||||||
|
kind,
|
||||||
|
fileName: file.name || (kind === "voice" ? "voice-message.webm" : "file"),
|
||||||
|
mimeType: file.type || "application/octet-stream",
|
||||||
|
content,
|
||||||
|
durationSeconds: typeof durationRaw === "string" ? Number(durationRaw) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message, attachment }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { markConversationRead } from "@/lib/chat/service";
|
||||||
|
|
||||||
|
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
await markConversationRead(id, session.user.id);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { getConversationForUser } from "@/lib/chat/service";
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const conv = await getConversationForUser(id, session.user.id);
|
||||||
|
if (!conv) return NextResponse.json({ error: "Не найдено" }, { status: 404 });
|
||||||
|
return NextResponse.json(conv);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { listConversationsForUser, getOrCreateDirectConversation, createGroupConversation } from "@/lib/chat/service";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
return NextResponse.json(await listConversationsForUser(session.user.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = z.union([
|
||||||
|
z.object({ peerId: z.string().min(1) }),
|
||||||
|
z.object({ name: z.string().min(1), participantIds: z.array(z.string().min(1)).min(1) }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(await request.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Некорректные данные" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("peerId" in parsed.data) {
|
||||||
|
const conv = await getOrCreateDirectConversation(session.user.id, parsed.data.peerId);
|
||||||
|
return NextResponse.json(conv, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const conv = await createGroupConversation(session.user.id, parsed.data.participantIds, parsed.data.name);
|
||||||
|
return NextResponse.json(conv, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { forwardMessage } from "@/lib/chat/service";
|
||||||
|
|
||||||
|
const schema = z.object({ targetConversationId: z.string().min(1) });
|
||||||
|
|
||||||
|
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const parsed = schema.safeParse(await request.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Некорректные данные" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = await forwardMessage(id, parsed.data.targetConversationId, session.user.id);
|
||||||
|
return NextResponse.json({ message }, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Не удалось переслать сообщение";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user