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:
ogrechkoandClaude Sonnet 5 committed 2026-08-31 16:33:30 +00:00
commit 579528edf7
197 files changed
+25022

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
.git
data
*.env
!.env.example
*.tsbuildinfo
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+44
View File
@@ -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`.
+16
View File
@@ -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:
+13
View File
@@ -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"),
},
});
+16
View File
@@ -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;
+11
View File
@@ -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;
+9338
View File
File diff suppressed because it is too large. Load diff
+49
View File
@@ -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"
}
}
+7
View File
@@ -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

+14
View File
@@ -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" }
]
}
+53
View File
@@ -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);
})(),
);
});
+46
View File
@@ -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);
});
+7
View File
@@ -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"));
+63
View File
@@ -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>
);
}
+250
View File
@@ -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>
);
}
+169
View File
@@ -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>
);
}
+137
View File
@@ -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>
);
}
+104
View File
@@ -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>
);
}
+97
View File
@@ -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,
};
}
+203
View File
@@ -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>
);
}
+166
View File
@@ -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>
);
}
+52
View File
@@ -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}
/>
);
}
+49
View File
@@ -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[];
}
+66
View File
@@ -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>
);
}
+23
View File
@@ -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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function FinancePage() {
redirect("/finance/payables");
}
+43
View File
@@ -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>
);
}
+139
View File
@@ -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>
);
}
+151
View File
@@ -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} &lt;{draft.fromEmail}&gt;
</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>
);
}
+40
View File
@@ -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>
);
}
+56
View File
@@ -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>
);
}
+37
View File
@@ -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}
/>
</>
);
}
+60
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+100
View File
@@ -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>
</>
);
}
+73
View File
@@ -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;
}
+35
View File
@@ -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>
);
}
+18
View File
@@ -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} />
</>
);
}
+40
View File
@@ -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>
);
}
+122
View File
@@ -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>
);
}
+78
View File
@@ -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>
);
}
+25
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+150
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+22
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+51
View File
@@ -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)} />
</>
);
}
+36
View File
@@ -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>
);
}
+237
View File
@@ -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>
);
}
+44
View File
@@ -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>
);
}
+25
View File
@@ -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>
);
}
+140
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default function PortalLayout({ children }: { children: React.ReactNode }) {
return <div className="min-h-screen bg-bg">{children}</div>;
}
+58
View File
@@ -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>
);
}
+30
View File
@@ -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)}`,
},
});
}
+63
View File
@@ -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 } });
}
+13
View File
@@ -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);
}
+35
View File
@@ -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