fix: RLS recursion, driver UI, auth errors, status counts
- Fix infinite RLS recursion in users policies via current_role_name() - Add is_admin() helper and simplify users select policies - Fix order_groups insert for service_role (n8n integration) - Fix status option counts in dropdown (LogisticsReadinessBoard) - Add driver delivery status buttons (loaded, on_route, delivered, problem, cancelled) - Replace driver date range filters with single date picker + date pills - Hide SMS/internal data from driver view in OrderDetailPanel - Fix Edge Function error messages in OTP flow (read error.context body) - Add update_delivery_status RPC for driver workflow - Remove customer column from order_groups fetch (DB schema mismatch) - UI: swap filter/search positions, limit modal width, status label cleanup
This commit is contained in:
parent
5e9da52690
commit
b9c6bb2810
|
|
@ -1,3 +1,4 @@
|
||||||
|
VITE_ENABLE_DEMO=false
|
||||||
VITE_SUPABASE_URL=https://your-project.supabase.co
|
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||||
VITE_SUPABASE_ANON_KEY=your-anon-key
|
VITE_SUPABASE_ANON_KEY=your-anon-key
|
||||||
APP_ALLOWED_ORIGINS=http://localhost:5173
|
APP_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.*
|
||||||
|
!.env.example
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.worktrees
|
.worktrees
|
||||||
.superpowers
|
.superpowers
|
||||||
|
.ruff_cache
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
stderr | src/pages/DashboardPage.test.jsx > DashboardPage > keeps the manager dashboard on the group registry only
|
||||||
|
Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes.
|
||||||
|
at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3)
|
||||||
|
|
||||||
|
stderr | src/pages/DashboardPage.test.jsx > DashboardPage > keeps the logistician dashboard free of bot control and extra workspace
|
||||||
|
Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes.
|
||||||
|
at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3)
|
||||||
|
|
||||||
|
stderr | src/pages/DashboardPage.test.jsx > DashboardPage > keeps the driver dashboard on the deliveries list only
|
||||||
|
Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes.
|
||||||
|
at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3)
|
||||||
|
|
||||||
|
stderr | .worktrees/codex-security-hardening/src/pages/DashboardPage.test.jsx > DashboardPage > keeps the manager dashboard on the delivery registry only
|
||||||
|
Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes.
|
||||||
|
at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3)
|
||||||
|
|
||||||
|
stderr | .worktrees/codex-security-hardening/src/pages/DashboardPage.test.jsx > DashboardPage > keeps the logistician dashboard free of bot control and extra workspace
|
||||||
|
Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes.
|
||||||
|
at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3)
|
||||||
|
|
||||||
|
stderr | .worktrees/codex-security-hardening/src/pages/DashboardPage.test.jsx > DashboardPage > keeps the driver dashboard on the deliveries list only
|
||||||
|
Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://reactjs.org/link/uselayouteffect-ssr for common fixes.
|
||||||
|
at MemoryRouter (file:///Users/mihailkucer/Documents/super-sam/node_modules/react-router/dist/development/chunk-LFPYN7LY.mjs:6569:3)
|
||||||
|
|
||||||
10
Caddyfile
10
Caddyfile
|
|
@ -3,4 +3,14 @@
|
||||||
root * /usr/share/caddy
|
root * /usr/share/caddy
|
||||||
file_server
|
file_server
|
||||||
|
|
||||||
|
header {
|
||||||
|
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://supa.supersamsev.ru; font-src 'self'; connect-src 'self' https://supa.supersamsev.ru; frame-ancestors 'none'; form-action 'self'; base-uri 'self'"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "DENY"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||||
|
X-XSS-Protection "0"
|
||||||
|
Cross-Origin-Opener-Policy "same-origin"
|
||||||
|
}
|
||||||
|
|
||||||
try_files {path} /index.html
|
try_files {path} /index.html
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,4 @@ FROM caddy:2-alpine
|
||||||
COPY --from=build /app/dist /usr/share/caddy
|
COPY --from=build /app/dist /usr/share/caddy
|
||||||
COPY Caddyfile /etc/caddy/Caddyfile
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
USER 1000:1000
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,17 @@ services:
|
||||||
- traefik.http.routers.supersam-app.tls.certresolver=letsencrypt
|
- traefik.http.routers.supersam-app.tls.certresolver=letsencrypt
|
||||||
- traefik.http.routers.supersam-app.service=supersam-app
|
- traefik.http.routers.supersam-app.service=supersam-app
|
||||||
- traefik.http.services.supersam-app.loadbalancer.server.port=80
|
- traefik.http.services.supersam-app.loadbalancer.server.port=80
|
||||||
|
# Redirect HTTP to HTTPS
|
||||||
|
- traefik.http.routers.supersam-app-http.rule=Host(`dost.supersamsev.ru`)
|
||||||
|
- traefik.http.routers.supersam-app-http.entryPoints=http
|
||||||
|
- traefik.http.routers.supersam-app-http.middlewares=redirect-to-https
|
||||||
|
- traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https
|
||||||
|
- traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true
|
||||||
|
# Security headers via Traefik
|
||||||
|
- traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Content-Type-Options=nosniff
|
||||||
|
- traefik.http.middlewares.supersam-sec.headers.customresponseheaders.X-Frame-Options=DENY
|
||||||
|
- traefik.http.middlewares.supersam-sec.headers.customresponseheaders.Referrer-Policy=strict-origin-when-cross-origin
|
||||||
|
- traefik.http.routers.supersam-app.middlewares=supersam-sec
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
coolify:
|
coolify:
|
||||||
|
|
|
||||||
|
|
@ -77,12 +77,11 @@ begin
|
||||||
);
|
);
|
||||||
v_customer_name := coalesce(
|
v_customer_name := coalesce(
|
||||||
nullif(v_group.customer_name, ''),
|
nullif(v_group.customer_name, ''),
|
||||||
nullif(v_group.customer ->> 'name', ''),
|
|
||||||
nullif(v_invitation.customer_name, '')
|
nullif(v_invitation.customer_name, '')
|
||||||
);
|
);
|
||||||
v_customer_phone := coalesce(
|
v_customer_phone := coalesce(
|
||||||
nullif(v_group.customer_phone, ''),
|
nullif(v_group.customer_phone, ''),
|
||||||
nullif(v_group.customer ->> 'phone', ''),
|
nullif(v_group.customer_phone_normalized, ''),
|
||||||
nullif(v_invitation.customer_phone, '')
|
nullif(v_invitation.customer_phone, '')
|
||||||
);
|
);
|
||||||
select coalesce(
|
select coalesce(
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,13 @@
|
||||||
<link rel="icon" type="image/svg+xml" href="/icons/icon-192.svg" />
|
<link rel="icon" type="image/svg+xml" href="/icons/icon-192.svg" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<title>Construction Delivery Control</title>
|
<title>Construction Delivery Control</title>
|
||||||
|
<script>
|
||||||
|
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
|
||||||
|
navigator.serviceWorker?.getRegistrations?.().then(function (regs) {
|
||||||
|
regs.forEach(function (r) { r.unregister(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^12.7.4",
|
"framer-motion": "^12.7.4",
|
||||||
|
"playwright": "^1.60.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^7.3.0",
|
"react-router-dom": "^7.3.0",
|
||||||
|
|
@ -4692,6 +4693,50 @@
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
|
|
|
||||||
17
package.json
17
package.json
|
|
@ -12,14 +12,15 @@
|
||||||
"anonymize:1c-xml": "node scripts/anonymize-1c-xml.mjs"
|
"anonymize:1c-xml": "node scripts/anonymize-1c-xml.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.52.0",
|
"@supabase/supabase-js": "2.52.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "4.1.0",
|
||||||
"framer-motion": "^12.7.4",
|
"framer-motion": "12.7.4",
|
||||||
"react": "^18.3.1",
|
"playwright": "1.60.0",
|
||||||
"react-dom": "^18.3.1",
|
"react": "18.3.1",
|
||||||
"react-router-dom": "^7.3.0",
|
"react-dom": "18.3.1",
|
||||||
"tailwind-merge": "^3.3.0"
|
"react-router-dom": "7.3.0",
|
||||||
|
"tailwind-merge": "3.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.22.0",
|
"@eslint/js": "^9.22.0",
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,82 @@
|
||||||
const STATIC_CACHE = "construction-delivery-static-v1";
|
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
||||||
const RUNTIME_CACHE = "construction-delivery-runtime-v1";
|
|
||||||
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.svg", "/icons/icon-512.svg"];
|
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
if (!isLocalhost) {
|
||||||
event.waitUntil(
|
const STATIC_CACHE = "construction-delivery-static-v1";
|
||||||
caches.open(STATIC_CACHE).then((cache) => cache.addAll(APP_SHELL_URLS)).then(() => self.skipWaiting()),
|
const RUNTIME_CACHE = "construction-delivery-runtime-v1";
|
||||||
);
|
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.svg", "/icons/icon-512.svg"];
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener("activate", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches
|
caches.open(STATIC_CACHE).then((cache) => cache.addAll(APP_SHELL_URLS)).then(() => self.skipWaiting()),
|
||||||
.keys()
|
);
|
||||||
.then((keys) =>
|
});
|
||||||
Promise.all(
|
|
||||||
keys
|
|
||||||
.filter((key) => ![STATIC_CACHE, RUNTIME_CACHE].includes(key))
|
|
||||||
.map((key) => caches.delete(key)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.then(() => self.clients.claim())
|
|
||||||
.then(async () => {
|
|
||||||
const clients = await self.clients.matchAll({ includeUncontrolled: true });
|
|
||||||
clients.forEach((client) => client.postMessage({ type: "PWA_OFFLINE_READY" }));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("activate", (event) => {
|
||||||
if (event.request.method !== "GET") {
|
event.waitUntil(
|
||||||
return;
|
caches
|
||||||
}
|
.keys()
|
||||||
|
.then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => ![STATIC_CACHE, RUNTIME_CACHE].includes(key))
|
||||||
|
.map((key) => caches.delete(key)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
.then(async () => {
|
||||||
|
const clients = await self.clients.matchAll({ includeUncontrolled: true });
|
||||||
|
clients.forEach((client) => client.postMessage({ type: "PWA_OFFLINE_READY" }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const requestUrl = new URL(event.request.url);
|
self.addEventListener("fetch", (event) => {
|
||||||
const isSameOrigin = requestUrl.origin === self.location.origin;
|
if (event.request.method !== "GET") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestUrl = new URL(event.request.url);
|
||||||
|
const isSameOrigin = requestUrl.origin === self.location.origin;
|
||||||
|
|
||||||
|
if (event.request.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
const responseClone = response.clone();
|
||||||
|
caches.open(RUNTIME_CACHE).then((cache) => cache.put(event.request, responseClone));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
const cachedPage = await caches.match(event.request);
|
||||||
|
return cachedPage || caches.match("/index.html");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSameOrigin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.request.mode === "navigate") {
|
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(event.request)
|
caches.match(event.request).then((cachedResponse) => {
|
||||||
.then((response) => {
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(event.request).then((response) => {
|
||||||
|
if (!response || response.status !== 200) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
const responseClone = response.clone();
|
const responseClone = response.clone();
|
||||||
caches.open(RUNTIME_CACHE).then((cache) => cache.put(event.request, responseClone));
|
caches.open(RUNTIME_CACHE).then((cache) => cache.put(event.request, responseClone));
|
||||||
return response;
|
return response;
|
||||||
})
|
});
|
||||||
.catch(async () => {
|
}),
|
||||||
const cachedPage = await caches.match(event.request);
|
|
||||||
return cachedPage || caches.match("/index.html");
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
return;
|
});
|
||||||
}
|
} else {
|
||||||
|
self.addEventListener("install", (event) => self.skipWaiting());
|
||||||
if (!isSameOrigin) {
|
self.addEventListener("activate", (event) => self.clients.claim());
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
event.respondWith(
|
|
||||||
caches.match(event.request).then((cachedResponse) => {
|
|
||||||
if (cachedResponse) {
|
|
||||||
return cachedResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetch(event.request).then((response) => {
|
|
||||||
if (!response || response.status !== 200) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseClone = response.clone();
|
|
||||||
caches.open(RUNTIME_CACHE).then((cache) => cache.put(event.request, responseClone));
|
|
||||||
return response;
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export const Badge = ({ children, tone = "neutral", className }) => {
|
||||||
"border-[rgba(18,128,92,0.18)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]": tone === "accent",
|
"border-[rgba(18,128,92,0.18)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]": tone === "accent",
|
||||||
"border-[rgba(201,61,61,0.22)] bg-[rgba(201,61,61,0.12)] text-[var(--color-danger)]": tone === "danger",
|
"border-[rgba(201,61,61,0.22)] bg-[rgba(201,61,61,0.12)] text-[var(--color-danger)]": tone === "danger",
|
||||||
"border-[rgba(191,123,33,0.22)] bg-[rgba(191,123,33,0.12)] text-[var(--color-warning)]": tone === "warning",
|
"border-[rgba(191,123,33,0.22)] bg-[rgba(191,123,33,0.12)] text-[var(--color-warning)]": tone === "warning",
|
||||||
|
"border-[rgba(33,111,191,0.22)] bg-[rgba(33,111,191,0.12)] text-[#216fbf]": tone === "info",
|
||||||
"border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)]": tone === "neutral",
|
"border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)]": tone === "neutral",
|
||||||
},
|
},
|
||||||
className,
|
className,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ export const Panel = ({ children, className, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-soft backdrop-blur",
|
"rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-soft",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
filterOrderGroups,
|
|
||||||
getOrderGroupDeliveryHalfDay,
|
getOrderGroupDeliveryHalfDay,
|
||||||
getOrderGroupDeliveryStatusLabel,
|
getOrderGroupDeliveryStatusLabel,
|
||||||
getOrderGroupDeliveryStatusTone,
|
getOrderGroupDeliveryStatusTone,
|
||||||
ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS,
|
|
||||||
DRIVER_VISIBLE_DELIVERY_STATUSES,
|
DRIVER_VISIBLE_DELIVERY_STATUSES,
|
||||||
isOrderGroupVisibleToDriver,
|
isOrderGroupVisibleToDriver,
|
||||||
groupOrderGroupsByDate,
|
groupOrderGroupsByDate,
|
||||||
|
|
@ -22,38 +20,59 @@ const DRIVER_DELIVERY_STATUS_OPTIONS = [
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder }) => {
|
export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder, currentUser }) => {
|
||||||
const [filters, setFilters] = React.useState({
|
const [filters, setFilters] = React.useState({
|
||||||
dateFrom: "",
|
selectedDate: "",
|
||||||
dateTo: "",
|
|
||||||
deliveryHalfDay: "all",
|
|
||||||
deliveryStatus: "all",
|
deliveryStatus: "all",
|
||||||
});
|
});
|
||||||
|
|
||||||
const agreedOrderGroups = React.useMemo(
|
const driverOrderGroups = React.useMemo(
|
||||||
() => orderGroups.filter((group) => isOrderGroupVisibleToDriver(group)),
|
() => orderGroups.filter((group) => {
|
||||||
[orderGroups],
|
const isVisible = isOrderGroupVisibleToDriver(group);
|
||||||
|
const isAssignedToMe = currentUser && group.assignedDriverId === currentUser.id;
|
||||||
|
return isVisible && isAssignedToMe;
|
||||||
|
}),
|
||||||
|
[orderGroups, currentUser],
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredOrderGroups = React.useMemo(
|
// Build map of date -> count for quick lookup
|
||||||
() =>
|
const dateDeliveryMap = React.useMemo(() => {
|
||||||
filterOrderGroups(agreedOrderGroups, {
|
const map = new Map();
|
||||||
dateFrom: filters.dateFrom,
|
driverOrderGroups.forEach((group) => {
|
||||||
dateTo: filters.dateTo,
|
const date = group.deliveryDate;
|
||||||
deliveryHalfDay: filters.deliveryHalfDay,
|
if (date) {
|
||||||
deliveryStatus: filters.deliveryStatus,
|
map.set(date, (map.get(date) || 0) + 1);
|
||||||
}),
|
}
|
||||||
[agreedOrderGroups, filters.dateFrom, filters.dateTo, filters.deliveryHalfDay, filters.deliveryStatus],
|
});
|
||||||
);
|
return map;
|
||||||
|
}, [driverOrderGroups]);
|
||||||
|
|
||||||
|
const sortedDeliveryDates = React.useMemo(() => {
|
||||||
|
return Array.from(dateDeliveryMap.keys()).sort();
|
||||||
|
}, [dateDeliveryMap]);
|
||||||
|
|
||||||
|
const filteredOrderGroups = React.useMemo(() => {
|
||||||
|
let result = [...driverOrderGroups];
|
||||||
|
if (filters.selectedDate) {
|
||||||
|
result = result.filter((group) => group.deliveryDate === filters.selectedDate);
|
||||||
|
}
|
||||||
|
if (filters.deliveryStatus !== "all") {
|
||||||
|
result = result.filter((group) => (group.deliveryStatus || group.delivery_status) === filters.deliveryStatus);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [driverOrderGroups, filters.selectedDate, filters.deliveryStatus]);
|
||||||
|
|
||||||
const groupedOrderGroups = React.useMemo(
|
const groupedOrderGroups = React.useMemo(
|
||||||
() => groupOrderGroupsByDate(filteredOrderGroups),
|
() => groupOrderGroupsByDate(filteredOrderGroups),
|
||||||
[filteredOrderGroups],
|
[filteredOrderGroups],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deliveryCountLabel = `${filteredOrderGroups.length} ${
|
const deliveryCountLabel = `${filteredOrderGroups.length} ${
|
||||||
filteredOrderGroups.length === 1 ? "доставка" : filteredOrderGroups.length < 5 ? "доставки" : "доставок"
|
filteredOrderGroups.length === 1 ? "доставка" : filteredOrderGroups.length < 5 ? "доставки" : "доставок"
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
const isDateSelected = (date) => filters.selectedDate === date;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Panel className="space-y-3 p-5">
|
<Panel className="space-y-3 p-5">
|
||||||
|
|
@ -65,49 +84,22 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder }) => {
|
||||||
<Badge tone="neutral">{deliveryCountLabel}</Badge>
|
<Badge tone="neutral">{deliveryCountLabel}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
Показываем только согласованные к доставке группы. Можно сузить список по дате и половине дня.
|
Показываем только согласованные к доставке группы. Выберите дату ниже.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-[repeat(4,minmax(0,1fr))]">
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||||
<label className="flex min-w-0 flex-col gap-2">
|
<label className="flex min-w-0 flex-col gap-2">
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||||
Дата от
|
Дата
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
value={filters.dateFrom}
|
value={filters.selectedDate}
|
||||||
onChange={(event) => setFilters((current) => ({ ...current, dateFrom: event.target.value }))}
|
onChange={(event) => setFilters((current) => ({ ...current, selectedDate: event.target.value }))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex min-w-0 flex-col gap-2">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
|
||||||
Дата до
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={filters.dateTo}
|
|
||||||
onChange={(event) => setFilters((current) => ({ ...current, dateTo: event.target.value }))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex min-w-0 flex-col gap-2">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
|
||||||
Время суток
|
|
||||||
</span>
|
|
||||||
<Select
|
|
||||||
value={filters.deliveryHalfDay}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFilters((current) => ({ ...current, deliveryHalfDay: event.target.value }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</label>
|
|
||||||
<label className="flex min-w-0 flex-col gap-2">
|
<label className="flex min-w-0 flex-col gap-2">
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||||
Статус
|
Статус
|
||||||
|
|
@ -126,6 +118,48 @@ export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder }) => {
|
||||||
</Select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Date pills showing days with deliveries */}
|
||||||
|
{sortedDeliveryDates.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilters((current) => ({ ...current, selectedDate: "" }))}
|
||||||
|
className={[
|
||||||
|
"rounded-full border px-3 py-1.5 text-xs font-medium transition",
|
||||||
|
!filters.selectedDate
|
||||||
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||||
|
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
Все даты
|
||||||
|
</button>
|
||||||
|
{sortedDeliveryDates.map((date) => {
|
||||||
|
const count = dateDeliveryMap.get(date) || 0;
|
||||||
|
const selected = isDateSelected(date);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={date}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilters((current) => ({ ...current, selectedDate: date }))}
|
||||||
|
className={[
|
||||||
|
"flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition",
|
||||||
|
selected
|
||||||
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||||
|
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<span>{new Date(`${date}T12:00:00`).toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}</span>
|
||||||
|
{count > 0 && (
|
||||||
|
<span className="rounded-full bg-[var(--color-accent)] px-1.5 py-0.5 text-[10px] font-bold text-white">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ const orderGroups = [
|
||||||
notReadyCount: 0,
|
notReadyCount: 0,
|
||||||
status: "ready_for_notification",
|
status: "ready_for_notification",
|
||||||
deliveryStatus: "agreed",
|
deliveryStatus: "agreed",
|
||||||
|
assignedDriverId: "driver-1",
|
||||||
deliveryHalfDay: "Первая половина дня",
|
deliveryHalfDay: "Первая половина дня",
|
||||||
smsSentAt: null,
|
smsSentAt: null,
|
||||||
updatedAt: "2026-04-16T12:00:00Z",
|
updatedAt: "2026-04-16T12:00:00Z",
|
||||||
|
|
@ -47,6 +48,7 @@ describe("DriverDeliveryPlanner", () => {
|
||||||
<DriverDeliveryPlanner
|
<DriverDeliveryPlanner
|
||||||
orderGroups={orderGroups}
|
orderGroups={orderGroups}
|
||||||
onOpenOrder={() => {}}
|
onOpenOrder={() => {}}
|
||||||
|
currentUser={{ id: "driver-1" }}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -55,8 +57,7 @@ describe("DriverDeliveryPlanner", () => {
|
||||||
expect(markup).toContain("Мария Волкова");
|
expect(markup).toContain("Мария Волкова");
|
||||||
expect(markup).toContain("CD-240031");
|
expect(markup).toContain("CD-240031");
|
||||||
expect(markup).not.toContain("Не показывать");
|
expect(markup).not.toContain("Не показывать");
|
||||||
expect(markup).toContain("Дата от");
|
expect(markup).toContain("Дата");
|
||||||
expect(markup).toContain("Время суток");
|
|
||||||
expect(markup).toContain("Статус");
|
expect(markup).toContain("Статус");
|
||||||
expect(markup).toContain("Согласовано");
|
expect(markup).toContain("Согласовано");
|
||||||
expect(markup).not.toContain("Канбан");
|
expect(markup).not.toContain("Канбан");
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,15 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
buildOrderGroupBuckets,
|
|
||||||
filterOrderGroups,
|
filterOrderGroups,
|
||||||
getOrderGroupDisplayStatusLabel,
|
getOrderGroupDisplayStatusLabel,
|
||||||
|
getOrderGroupDisplayStatusValue,
|
||||||
getOrderGroupStatusTone,
|
getOrderGroupStatusTone,
|
||||||
ORDER_GROUP_BUCKET_LABELS,
|
|
||||||
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
|
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
|
||||||
} from "../../services/orderGroupViews";
|
} from "../../services/orderGroupViews";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { OrderFilters } from "../orders/OrderFilters";
|
import { OrderFilters } from "../orders/OrderFilters";
|
||||||
|
|
||||||
const BUCKET_ICONS = {
|
|
||||||
ready_to_launch: "\u2713",
|
|
||||||
sms_sent: "\u2709",
|
|
||||||
manual_work: "\u26A0",
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderOrderNumbers = (group) => {
|
const renderOrderNumbers = (group) => {
|
||||||
if (!Array.isArray(group.orderNumbers) || !group.orderNumbers.length) {
|
if (!Array.isArray(group.orderNumbers) || !group.orderNumbers.length) {
|
||||||
return <span>Номера не указаны</span>;
|
return <span>Номера не указаны</span>;
|
||||||
|
|
@ -36,20 +29,28 @@ const renderOrderNumbers = (group) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet }) => {
|
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS }) => {
|
||||||
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all" });
|
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all" });
|
||||||
|
|
||||||
const filteredGroups = React.useMemo(
|
const filteredGroups = React.useMemo(
|
||||||
() => filterOrderGroups(orderGroups, filters),
|
() => filterOrderGroups(orderGroups, filters),
|
||||||
[filters, orderGroups],
|
[filters, orderGroups],
|
||||||
);
|
);
|
||||||
const deliveryGroupBuckets = React.useMemo(
|
|
||||||
() => buildOrderGroupBuckets(filteredGroups),
|
|
||||||
[filteredGroups],
|
|
||||||
);
|
|
||||||
|
|
||||||
const bucketKeys = Object.keys(ORDER_GROUP_BUCKET_LABELS);
|
// Group by display status value
|
||||||
const buckets = deliveryGroupBuckets || {};
|
const statusGroups = React.useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const group of filteredGroups) {
|
||||||
|
const statusValue = getOrderGroupDisplayStatusValue(group);
|
||||||
|
if (!map.has(statusValue)) {
|
||||||
|
const label = getOrderGroupDisplayStatusLabel(group);
|
||||||
|
map.set(statusValue, { label, groups: [] });
|
||||||
|
}
|
||||||
|
map.get(statusValue).groups.push(group);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [filteredGroups]);
|
||||||
|
|
||||||
const totalGroups = filteredGroups.length;
|
const totalGroups = filteredGroups.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -65,39 +66,24 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet }) => {
|
||||||
<OrderFilters
|
<OrderFilters
|
||||||
filters={filters}
|
filters={filters}
|
||||||
setFilters={setFilters}
|
setFilters={setFilters}
|
||||||
statusOptions={ORDER_GROUP_DISPLAY_STATUS_OPTIONS}
|
statusOptions={statusOptions}
|
||||||
/>
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{!totalGroups ? (
|
{!totalGroups ? (
|
||||||
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||||
По этому поиску ничего не найдено.
|
По этому поиску ничего не найдено.
|
||||||
</Panel>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 xl:grid-cols-2">
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
{bucketKeys.map((bucketKey) => {
|
{Array.from(statusGroups.entries()).map(([statusValue, { label, groups }]) => {
|
||||||
const groups = buckets[bucketKey] || [];
|
if (!groups.length) return null;
|
||||||
const label = ORDER_GROUP_BUCKET_LABELS[bucketKey];
|
|
||||||
const icon = BUCKET_ICONS[bucketKey];
|
|
||||||
|
|
||||||
if (!groups.length) {
|
|
||||||
return (
|
|
||||||
<Panel key={bucketKey} className="p-5 opacity-50">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-lg">{icon}</span>
|
|
||||||
<h3 className="font-semibold">{label}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm text-[var(--color-text-muted)]">Нет групп</p>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={bucketKey} className="space-y-3">
|
<div key={statusValue} className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-lg">{icon}</span>
|
|
||||||
<h3 className="font-semibold">{label}</h3>
|
<h3 className="font-semibold">{label}</h3>
|
||||||
<Badge tone={bucketKey === "sms_sent" ? "accent" : "neutral"}>{groups.length}</Badge>
|
<Badge tone="neutral">{groups.length}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
||||||
import { formatDateTime } from "../../utils/formatters";
|
import { formatDateTime } from "../../utils/formatters";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Button } from "../UI/Button";
|
import { Button } from "../UI/Button";
|
||||||
|
import { Select } from "../UI/Select";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import {
|
import {
|
||||||
getOrderGroupDeliveryStatusLabel,
|
getOrderGroupDeliveryStatusLabel,
|
||||||
|
|
@ -98,6 +99,14 @@ export const getNextSelectableDateKey = (referenceDate = new Date()) => {
|
||||||
return toDateKey(current);
|
return toDateKey(current);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizePhoneForTel = (phone) => {
|
||||||
|
const cleaned = String(phone || "").trim();
|
||||||
|
if (!cleaned) return "";
|
||||||
|
if (cleaned.startsWith("+7")) return cleaned;
|
||||||
|
if (cleaned.startsWith("8")) return "+7" + cleaned.slice(1);
|
||||||
|
return "+7" + cleaned;
|
||||||
|
};
|
||||||
|
|
||||||
const isFutureDeliveryDate = (value) => {
|
const isFutureDeliveryDate = (value) => {
|
||||||
const parsedDate = fromDateKey(value);
|
const parsedDate = fromDateKey(value);
|
||||||
|
|
||||||
|
|
@ -190,11 +199,17 @@ export const OrderDetailPanel = ({
|
||||||
canManageDelivery = false,
|
canManageDelivery = false,
|
||||||
onSaveManualDeliveryChoice,
|
onSaveManualDeliveryChoice,
|
||||||
isSavingDeliveryChoice = false,
|
isSavingDeliveryChoice = false,
|
||||||
|
drivers = [],
|
||||||
|
onAssignDriver,
|
||||||
|
onChangeDeliveryStatus,
|
||||||
|
userRole,
|
||||||
}) => {
|
}) => {
|
||||||
const [deliveryDate, setDeliveryDate] = React.useState("");
|
const [deliveryDate, setDeliveryDate] = React.useState("");
|
||||||
const [deliveryTime, setDeliveryTime] = React.useState(DELIVERY_TIME_OPTIONS[0]);
|
const [deliveryTime, setDeliveryTime] = React.useState(DELIVERY_TIME_OPTIONS[0]);
|
||||||
const [formMessage, setFormMessage] = React.useState("");
|
const [formMessage, setFormMessage] = React.useState("");
|
||||||
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
|
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
|
||||||
|
const [driverMessage, setDriverMessage] = React.useState("");
|
||||||
|
const [selectedDriverId, setSelectedDriverId] = React.useState(order?.assignedDriverId || "");
|
||||||
const minSelectableDateKey = React.useMemo(() => getNextSelectableDateKey(), []);
|
const minSelectableDateKey = React.useMemo(() => getNextSelectableDateKey(), []);
|
||||||
const [currentMonth, setCurrentMonth] = React.useState(() => {
|
const [currentMonth, setCurrentMonth] = React.useState(() => {
|
||||||
const existingDeliveryDate = fromDateKey(order?.deliveryDate);
|
const existingDeliveryDate = fromDateKey(order?.deliveryDate);
|
||||||
|
|
@ -216,6 +231,10 @@ export const OrderDetailPanel = ({
|
||||||
);
|
);
|
||||||
const canGoBack = toDateKey(currentMonth) > toDateKey(startOfMonth(fromDateKey(minSelectableDateKey) || new Date()));
|
const canGoBack = toDateKey(currentMonth) > toDateKey(startOfMonth(fromDateKey(minSelectableDateKey) || new Date()));
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setSelectedDriverId(order?.assignedDriverId || "");
|
||||||
|
}, [order?.assignedDriverId]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate);
|
const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate);
|
||||||
const nextSelectableDateKey = getNextSelectableDateKey();
|
const nextSelectableDateKey = getNextSelectableDateKey();
|
||||||
|
|
@ -270,6 +289,25 @@ export const OrderDetailPanel = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAssignDriver = async () => {
|
||||||
|
if (!selectedDriverId) {
|
||||||
|
setDriverMessage("Выберите водителя");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDriverMessage("");
|
||||||
|
const response = await onAssignDriver({
|
||||||
|
orderGroupId: order.id,
|
||||||
|
driverId: selectedDriverId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
setDriverMessage(response.error || "Не удалось назначить водителя");
|
||||||
|
} else {
|
||||||
|
setDriverMessage("Водитель назначен");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<Panel className="space-y-5 p-6">
|
<Panel className="space-y-5 p-6">
|
||||||
|
|
@ -288,65 +326,160 @@ export const OrderDetailPanel = ({
|
||||||
<Badge tone={getOrderGroupStatusTone(order)}>{getOrderGroupDisplayStatusLabel(order)}</Badge>
|
<Badge tone={getOrderGroupStatusTone(order)}>{getOrderGroupDisplayStatusLabel(order)}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4 md:grid-cols-2">
|
<div className="grid gap-3 rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 md:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||||
Дата доставки
|
Дата доставки
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xl font-semibold">{formatDeliveryDateDisplay(order.deliveryDate)}</p>
|
<p className="mt-1 text-base font-medium">{formatDeliveryDateDisplay(order.deliveryDate)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||||
Время доставки
|
Время доставки
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xl font-semibold">{renderValue(order.deliveryTime || order.deliveryHalfDay)}</p>
|
<p className="mt-1 text-base font-medium">{renderValue(order.deliveryTime || order.deliveryHalfDay)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||||
|
Водитель
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-base font-medium">{order.assignedDriverId ? renderValue(order.assignedDriverName) : "Не назначен"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||||
|
Телефон
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={`tel:${normalizePhoneForTel(order.customerPhone)}`}
|
||||||
|
className="mt-1 block text-base font-medium text-[var(--color-accent)] hover:underline"
|
||||||
|
>
|
||||||
|
{renderValue(order.customerPhone)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||||
|
Адрес доставки
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-base font-medium">{renderValue(order.deliveryAddress)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-x-4 gap-y-2 grid-cols-2 md:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Группа</p>
|
<p className="text-xs text-[var(--color-text-muted)]">Номер счёта</p>
|
||||||
<p className="mt-1 font-medium">{renderValue(order.groupKey)}</p>
|
<p className="font-medium">{renderValue(order.orderNumberSummary)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Клиент</p>
|
<p className="text-xs text-[var(--color-text-muted)]">Клиент</p>
|
||||||
<p className="mt-1 font-medium">{renderValue(order.customerName)}</p>
|
<p className="font-medium">{renderValue(order.customerName)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Телефон</p>
|
<p className="text-xs text-[var(--color-text-muted)]">Дата счёта</p>
|
||||||
<p className="mt-1 font-medium">{renderValue(order.customerPhone)}</p>
|
<p className="font-medium">{renderValue(order.customerDate)}</p>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Дата</p>
|
|
||||||
<p className="mt-1 font-medium">{renderValue(order.customerDate)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-2 xl:col-span-4">
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Адрес доставки</p>
|
|
||||||
<p className="mt-1 font-medium">{renderValue(order.deliveryAddress)}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Всего заказов</p>
|
<p className="text-xs text-[var(--color-text-muted)]">Всего заказов</p>
|
||||||
<p className="mt-1 font-medium">{order.ordersCount ?? 0}</p>
|
<p className="font-medium">{order.ordersCount ?? 0}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Готово</p>
|
<p className="text-xs text-[var(--color-text-muted)]">Готово</p>
|
||||||
<p className="mt-1 font-medium">{order.readyCount ?? 0}</p>
|
<p className="font-medium">{order.readyCount ?? 0}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Не готово</p>
|
<p className="text-xs text-[var(--color-text-muted)]">Не готово</p>
|
||||||
<p className="mt-1 font-medium">{order.notReadyCount ?? 0}</p>
|
<p className="font-medium">{order.notReadyCount ?? 0}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Обновлена</p>
|
<p className="text-xs text-[var(--color-text-muted)]">Обновлена</p>
|
||||||
<p className="mt-1 font-medium">{formatDateTime(order.updatedAt)}</p>
|
<p className="font-medium">{formatDateTime(order.updatedAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Статус доставки</p>
|
<p className="text-xs text-[var(--color-text-muted)]">Статус доставки</p>
|
||||||
<p className="mt-1 font-medium">{getOrderGroupDeliveryStatusLabel(order.deliveryStatus)}</p>
|
<p className="font-medium">{getOrderGroupDeliveryStatusLabel(order.deliveryStatus || order.delivery_status)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
{canManageDelivery && ["manager", "logistician", "admin"].includes(userRole) ? (
|
||||||
|
<Panel className="space-y-4 p-5">
|
||||||
|
<div>
|
||||||
|
<strong>Назначение водителя</strong>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{order.assignedDriverId
|
||||||
|
? `Назначен водитель: ${order.assignedDriverName || "Неизвестно"}. Вы можете изменить назначение.`
|
||||||
|
: "Выберите водителя для доставки."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-[minmax(16rem,24rem)_auto]">
|
||||||
|
<Select
|
||||||
|
className="h-[46px] py-0"
|
||||||
|
value={selectedDriverId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedDriverId(e.target.value);
|
||||||
|
setDriverMessage("");
|
||||||
|
}}
|
||||||
|
disabled={isSavingDeliveryChoice}
|
||||||
|
>
|
||||||
|
<option value="">{order.assignedDriverId ? "Сменить водителя..." : "Выберите водителя..."}</option>
|
||||||
|
{drivers.map((driver) => (
|
||||||
|
<option key={driver.id} value={driver.id}>{driver.name || driver.email}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
className="md:px-4 md:py-2 md:whitespace-nowrap md:self-start"
|
||||||
|
onClick={handleAssignDriver}
|
||||||
|
disabled={isSavingDeliveryChoice || !selectedDriverId}
|
||||||
|
>
|
||||||
|
{isSavingDeliveryChoice ? "Назначаем..." : "Назначить"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{driverMessage ? (
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">{driverMessage}</p>
|
||||||
|
) : null}
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{userRole === "driver" && order && onChangeDeliveryStatus ? (
|
||||||
|
<Panel className="space-y-4 p-5">
|
||||||
|
<div>
|
||||||
|
<strong>Статус доставки</strong>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
|
Обновите статус по мере выполнения доставки.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[
|
||||||
|
{ value: "loaded", label: "Загружено" },
|
||||||
|
{ value: "on_route", label: "В пути" },
|
||||||
|
{ value: "delivered", label: "Доставлено" },
|
||||||
|
{ value: "problem", label: "Проблема" },
|
||||||
|
{ value: "cancelled", label: "Отменено" },
|
||||||
|
].map((statusOption) => (
|
||||||
|
<Button
|
||||||
|
key={statusOption.value}
|
||||||
|
variant={
|
||||||
|
(order.deliveryStatus || order.delivery_status) === statusOption.value ? "primary" : "secondary"}
|
||||||
|
onClick={() => {
|
||||||
|
onChangeDeliveryStatus({
|
||||||
|
orderGroupId: order.id,
|
||||||
|
status: statusOption.value,
|
||||||
|
}).then((response) => {
|
||||||
|
if (!response.success) {
|
||||||
|
setFormMessage(response.error || "Не удалось обновить статус");
|
||||||
|
} else {
|
||||||
|
setFormMessage("");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isSavingDeliveryChoice}
|
||||||
|
>
|
||||||
|
{statusOption.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{canManageDelivery ? (
|
{canManageDelivery ? (
|
||||||
<Panel className="space-y-4 p-5">
|
<Panel className="space-y-4 p-5">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -521,12 +654,31 @@ export const OrderDetailPanel = ({
|
||||||
{renderList(order.orderNumbers)}
|
{renderList(order.orderNumbers)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel className="space-y-4 p-5">
|
{userRole !== "driver" ? (
|
||||||
<strong>Дополнительные данные</strong>
|
<Panel className="space-y-4 p-5">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<strong>Дополнительные данные</strong>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{order.firstSmsSentAt ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">1-е SMS отправлено</p>
|
||||||
|
<p className="mt-1 font-medium">{formatDateTime(order.firstSmsSentAt)}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{order.secondSmsSentAt ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">2-е SMS отправлено</p>
|
||||||
|
<p className="mt-1 font-medium">{formatDateTime(order.secondSmsSentAt)}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!order.firstSmsSentAt && !order.secondSmsSentAt ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">SMS отправлено</p>
|
||||||
|
<p className="mt-1 font-medium">Нет</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">SMS отправлено</p>
|
<p className="text-xs text-[var(--color-text-muted)]">Ручное согласование выполнено</p>
|
||||||
<p className="mt-1 font-medium">{order.smsSentAt ? "Да" : "Нет"}</p>
|
<p className="mt-1 font-medium">{order.manualConfirmationAt ? formatDateTime(order.manualConfirmationAt) : "Нет"}</p>
|
||||||
</div>
|
</div>
|
||||||
{order.createdFromExchangeAt ? (
|
{order.createdFromExchangeAt ? (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -534,14 +686,9 @@ export const OrderDetailPanel = ({
|
||||||
<p className="mt-1 font-medium">{formatDateTime(order.createdFromExchangeAt)}</p>
|
<p className="mt-1 font-medium">{formatDateTime(order.createdFromExchangeAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{order.sourceKey ? (
|
</div>
|
||||||
<div>
|
</Panel>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Ключ источника</p>
|
) : null}
|
||||||
<p className="mt-1 font-medium">{order.sourceKey}</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Badge } from "../UI/Badge";
|
|
||||||
import { Input } from "../UI/Input";
|
import { Input } from "../UI/Input";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
|
|
||||||
|
|
@ -39,22 +38,10 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [] }) => {
|
||||||
setFilters((current) => ({ ...current, [key]: value }));
|
setFilters((current) => ({ ...current, [key]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeChips = [statusValue !== "all" ? { key: "status", label: selectedStatusLabel } : null].filter(Boolean);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="p-4">
|
<Panel className="p-4">
|
||||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1.6fr)_minmax(12rem,0.7fr)] md:items-end">
|
<div className="grid gap-3 md:grid-cols-[minmax(12rem,0.7fr)_minmax(0,1.6fr)] md:items-end">
|
||||||
<Input
|
|
||||||
className="h-[46px] py-0"
|
|
||||||
placeholder="Поиск по группе, клиенту или телефону"
|
|
||||||
value={filters.query}
|
|
||||||
onChange={(event) => updateFilter("query", event.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div ref={statusMenuRef} className="relative flex min-w-0 flex-col gap-2">
|
<div ref={statusMenuRef} className="relative flex min-w-0 flex-col gap-2">
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
|
||||||
Статус
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
|
|
@ -76,7 +63,7 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [] }) => {
|
||||||
{isStatusOpen ? (
|
{isStatusOpen ? (
|
||||||
<div
|
<div
|
||||||
role="listbox"
|
role="listbox"
|
||||||
className="absolute left-0 right-0 top-full z-20 mt-2 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] shadow-soft"
|
className="absolute left-0 right-0 top-full z-20 mt-2 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-dropdown-surface)] shadow-soft"
|
||||||
>
|
>
|
||||||
{statusOptions.map((option) => {
|
{statusOptions.map((option) => {
|
||||||
const isSelected = option.value === statusValue;
|
const isSelected = option.value === statusValue;
|
||||||
|
|
@ -99,6 +86,9 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [] }) => {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 truncate">{option.label}</span>
|
<span className="min-w-0 flex-1 truncate">{option.label}</span>
|
||||||
|
<span className="ml-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-0.5 text-xs font-semibold text-[var(--color-text)]">
|
||||||
|
{option.count || 0}
|
||||||
|
</span>
|
||||||
{isSelected ? <span className="ml-3 text-[var(--color-accent)]">✓</span> : null}
|
{isSelected ? <span className="ml-3 text-[var(--color-accent)]">✓</span> : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
@ -106,15 +96,13 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [] }) => {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
<Input
|
||||||
|
className="h-[46px] py-0"
|
||||||
|
placeholder="Поиск по группе, клиенту или телефону"
|
||||||
|
value={filters.query}
|
||||||
|
onChange={(event) => updateFilter("query", event.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeChips.length ? (
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{activeChips.map((chip) => (
|
|
||||||
<Badge key={chip.key}>{chip.label}</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,9 @@ export const OrdersTable = ({
|
||||||
|
|
||||||
<div className="space-y-3 p-4 md:hidden">
|
<div className="space-y-3 p-4 md:hidden">
|
||||||
{!orderGroups.length ? (
|
{!orderGroups.length ? (
|
||||||
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||||
Группы не найдены. Попробуйте изменить поиск или статус.
|
Группы не найдены. Попробуйте изменить поиск или статус.
|
||||||
</Panel>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{orderGroups.map((group) => (
|
{orderGroups.map((group) => (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,22 @@ import { supabase, hasSupabaseConfig } from "../supabaseClient";
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
const AuthContext = createContext(null);
|
||||||
const STORAGE_KEY = "construction-auth-local-user";
|
const STORAGE_KEY = "construction-auth-local-user";
|
||||||
|
|
||||||
|
const encodeLocalAuth = (data) => {
|
||||||
|
try {
|
||||||
|
return btoa(encodeURIComponent(JSON.stringify(data)));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const decodeLocalAuth = (raw) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(decodeURIComponent(atob(raw)));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
export const PROFILE_LOAD_ERROR = "Не удалось загрузить профиль пользователя.";
|
export const PROFILE_LOAD_ERROR = "Не удалось загрузить профиль пользователя.";
|
||||||
export const UNKNOWN_EMAIL_ERROR = "Email не найден в системе. Обратитесь к администратору.";
|
export const UNKNOWN_EMAIL_ERROR = "Email не найден в системе. Обратитесь к администратору.";
|
||||||
|
|
||||||
|
|
@ -14,6 +30,9 @@ const UNKNOWN_EMAIL_ERROR_PATTERNS = [
|
||||||
/invalid login credentials/i,
|
/invalid login credentials/i,
|
||||||
/signup is disabled/i,
|
/signup is disabled/i,
|
||||||
/sign up is disabled/i,
|
/sign up is disabled/i,
|
||||||
|
/signups not allowed/i,
|
||||||
|
/email not registered/i,
|
||||||
|
/email address is not verified/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
const STALE_REFRESH_TOKEN_PATTERNS = [
|
const STALE_REFRESH_TOKEN_PATTERNS = [
|
||||||
|
|
@ -78,15 +97,32 @@ export const mapSessionUserToAuthUser = (sessionUser) => {
|
||||||
id: sessionUser.id,
|
id: sessionUser.id,
|
||||||
email: sessionUser.email,
|
email: sessionUser.email,
|
||||||
name: userMetadata.name || sessionUser.email || "Пользователь",
|
name: userMetadata.name || sessionUser.email || "Пользователь",
|
||||||
role: userMetadata.role || appMetadata.role || "manager",
|
role: userMetadata.role || appMetadata.role || null,
|
||||||
lastLogin: sessionUser.last_sign_in_at || sessionUser.updated_at || null,
|
lastLogin: sessionUser.last_sign_in_at || sessionUser.updated_at || null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchUserProfile = async (userId) => {
|
||||||
|
if (!supabase || !userId) return null;
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("users")
|
||||||
|
.select("id, email, name, role_id, last_login, roles(name)")
|
||||||
|
.eq("id", userId)
|
||||||
|
.single();
|
||||||
|
if (error || !data) return null;
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
email: data.email,
|
||||||
|
name: data.name,
|
||||||
|
role_info: data.roles,
|
||||||
|
last_login: data.last_login,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const AuthProvider = ({ children }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [user, setUser] = useState(() => {
|
const [user, setUser] = useState(() => {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
return stored ? JSON.parse(stored) : null;
|
return stored ? decodeLocalAuth(stored) : null;
|
||||||
});
|
});
|
||||||
const [pendingEmail, setPendingEmail] = useState("");
|
const [pendingEmail, setPendingEmail] = useState("");
|
||||||
const [isOtpSent, setIsOtpSent] = useState(false);
|
const [isOtpSent, setIsOtpSent] = useState(false);
|
||||||
|
|
@ -107,8 +143,18 @@ export const AuthProvider = ({ children }) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextUser = mapSessionUserToAuthUser(session.user);
|
const baseUser = mapSessionUserToAuthUser(session.user);
|
||||||
setUser(nextUser);
|
if (baseUser) {
|
||||||
|
fetchUserProfile(session.user.id).then((profile) => {
|
||||||
|
if (profile) {
|
||||||
|
setUser(mapProfileToAuthUser(profile));
|
||||||
|
} else {
|
||||||
|
setUser({ ...baseUser, role: baseUser.role || "manager" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
setAuthError("");
|
setAuthError("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -121,7 +167,16 @@ export const AuthProvider = ({ children }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.session?.user) {
|
if (data.session?.user) {
|
||||||
setUser(mapSessionUserToAuthUser(data.session.user));
|
const baseUser = mapSessionUserToAuthUser(data.session.user);
|
||||||
|
if (baseUser) {
|
||||||
|
fetchUserProfile(data.session.user.id).then((profile) => {
|
||||||
|
if (profile) {
|
||||||
|
setUser(mapProfileToAuthUser(profile));
|
||||||
|
} else {
|
||||||
|
setUser({ ...baseUser, role: baseUser.role || "manager" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -129,10 +184,10 @@ export const AuthProvider = ({ children }) => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user && !hasSupabaseConfig) {
|
if (user && isDemoMode) {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
|
const encoded = encodeLocalAuth(user); if (encoded) localStorage.setItem(STORAGE_KEY, encoded);
|
||||||
}
|
}
|
||||||
if (!user && !hasSupabaseConfig) {
|
if (!user && isDemoMode) {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
@ -148,7 +203,17 @@ export const AuthProvider = ({ children }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error || data?.ok === false) {
|
if (error || data?.ok === false) {
|
||||||
throw normalizeOtpError(error || new Error(data?.error || PROFILE_LOAD_ERROR));
|
let edgeErrorMessage = data?.error;
|
||||||
|
if (!edgeErrorMessage && typeof Response !== "undefined" && error?.context instanceof Response) {
|
||||||
|
try {
|
||||||
|
const cloned = error.context.clone();
|
||||||
|
const body = await cloned.json();
|
||||||
|
edgeErrorMessage = body?.error || body?.message;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore parse failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw normalizeOtpError(new Error(edgeErrorMessage || (error instanceof Error ? error.message : String(error)) || PROFILE_LOAD_ERROR));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem("construction-auth-role-hint", roleHint || "manager");
|
localStorage.setItem("construction-auth-role-hint", roleHint || "manager");
|
||||||
|
|
@ -174,7 +239,17 @@ export const AuthProvider = ({ children }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error || data?.ok === false) {
|
if (error || data?.ok === false) {
|
||||||
throw normalizeOtpError(error || new Error(data?.error || PROFILE_LOAD_ERROR));
|
let edgeErrorMessage = data?.error;
|
||||||
|
if (!edgeErrorMessage && typeof Response !== "undefined" && error?.context instanceof Response) {
|
||||||
|
try {
|
||||||
|
const cloned = error.context.clone();
|
||||||
|
const body = await cloned.json();
|
||||||
|
edgeErrorMessage = body?.error || body?.message;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore parse failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw normalizeOtpError(new Error(edgeErrorMessage || (error instanceof Error ? error.message : String(error)) || PROFILE_LOAD_ERROR));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data?.session?.access_token && data?.session?.refresh_token) {
|
if (data?.session?.access_token && data?.session?.refresh_token) {
|
||||||
|
|
@ -187,7 +262,15 @@ export const AuthProvider = ({ children }) => {
|
||||||
throw normalizeOtpError(sessionError);
|
throw normalizeOtpError(sessionError);
|
||||||
}
|
}
|
||||||
|
|
||||||
setUser(mapSessionUserToAuthUser(sessionData.session?.user || data.session.user));
|
const baseUser = mapSessionUserToAuthUser(sessionData.session?.user || data.session.user);
|
||||||
|
if (baseUser) {
|
||||||
|
const profile = await fetchUserProfile(baseUser.id);
|
||||||
|
if (profile) {
|
||||||
|
setUser(mapProfileToAuthUser(profile));
|
||||||
|
} else {
|
||||||
|
setUser({ ...baseUser, role: baseUser.role || "manager" });
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setUser(mapSessionUserToAuthUser(data?.user || null));
|
setUser(mapSessionUserToAuthUser(data?.user || null));
|
||||||
}
|
}
|
||||||
|
|
@ -222,13 +305,15 @@ export const AuthProvider = ({ children }) => {
|
||||||
setAuthError("");
|
setAuthError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isDemoMode = !hasSupabaseConfig && import.meta.env.VITE_ENABLE_DEMO === "true";
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
user,
|
user,
|
||||||
pendingEmail,
|
pendingEmail,
|
||||||
isOtpSent,
|
isOtpSent,
|
||||||
isLoading,
|
isLoading,
|
||||||
authError,
|
authError,
|
||||||
isDemoMode: !hasSupabaseConfig,
|
isDemoMode,
|
||||||
requestOtp,
|
requestOtp,
|
||||||
verifyOtp,
|
verifyOtp,
|
||||||
signOut,
|
signOut,
|
||||||
|
|
|
||||||
|
|
@ -816,6 +816,8 @@ export const demoOrderGroups = [
|
||||||
deliveryStatus: "driver_assigned",
|
deliveryStatus: "driver_assigned",
|
||||||
deliveryHalfDay: "Вторая половина дня",
|
deliveryHalfDay: "Вторая половина дня",
|
||||||
smsSentAt: "2026-05-05T11:10:00+00:00",
|
smsSentAt: "2026-05-05T11:10:00+00:00",
|
||||||
|
firstSmsSentAt: "2026-05-05T11:10:00+00:00",
|
||||||
|
notificationStatus: "first_sms_sent",
|
||||||
createdFromExchangeAt: "2026-05-05T09:20:00+00:00",
|
createdFromExchangeAt: "2026-05-05T09:20:00+00:00",
|
||||||
sourceKey: "1c-21974",
|
sourceKey: "1c-21974",
|
||||||
legacyCustomerName: null,
|
legacyCustomerName: null,
|
||||||
|
|
@ -896,6 +898,9 @@ export const demoOrderGroups = [
|
||||||
deliveryStatus: "on_route",
|
deliveryStatus: "on_route",
|
||||||
deliveryHalfDay: "Вторая половина дня",
|
deliveryHalfDay: "Вторая половина дня",
|
||||||
smsSentAt: "2026-05-05T12:45:00+00:00",
|
smsSentAt: "2026-05-05T12:45:00+00:00",
|
||||||
|
firstSmsSentAt: "2026-05-05T12:45:00+00:00",
|
||||||
|
secondSmsSentAt: "2026-05-05T14:00:00+00:00",
|
||||||
|
notificationStatus: "second_sms_sent",
|
||||||
createdFromExchangeAt: null,
|
createdFromExchangeAt: null,
|
||||||
sourceKey: null,
|
sourceKey: null,
|
||||||
legacyCustomerName: null,
|
legacyCustomerName: null,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { demoOrderGroups } from "../data/mockAppData";
|
import { assignDriverToOrderGroup, fetchOrderGroups, updateDeliveryStatus, updateOrderGroupDeliveryChoice } from "../services/supabase/orderGroupRepository";
|
||||||
import { fetchOrderGroups, updateOrderGroupDeliveryChoice } from "../services/supabase/orderGroupRepository";
|
|
||||||
import {
|
import {
|
||||||
buildOrderGroupBuckets,
|
buildOrderGroupBuckets,
|
||||||
filterOrderGroups,
|
filterOrderGroups,
|
||||||
groupOrderGroupsByDate,
|
groupOrderGroupsByDate,
|
||||||
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
|
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
|
||||||
|
getOrderGroupDisplayStatusValue,
|
||||||
} from "../services/orderGroupViews";
|
} from "../services/orderGroupViews";
|
||||||
import { hasSupabaseConfig } from "../supabaseClient";
|
|
||||||
|
|
||||||
const cloneLiveGroups = (groups) => (Array.isArray(groups) ? groups.map((group) => ({ ...group })) : []);
|
|
||||||
|
|
||||||
const getErrorMessage = (error, fallbackMessage) => {
|
const getErrorMessage = (error, fallbackMessage) => {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
|
|
@ -28,17 +25,13 @@ const getErrorMessage = (error, fallbackMessage) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useOrderGroups = () => {
|
export const useOrderGroups = () => {
|
||||||
const [orderGroups, setOrderGroups] = React.useState(() =>
|
const [orderGroups, setOrderGroups] = React.useState(() => []);
|
||||||
hasSupabaseConfig ? [] : cloneLiveGroups(demoOrderGroups),
|
|
||||||
);
|
|
||||||
const [filters, setFilters] = React.useState({
|
const [filters, setFilters] = React.useState({
|
||||||
query: "",
|
query: "",
|
||||||
displayStatus: "all",
|
displayStatus: "all",
|
||||||
});
|
});
|
||||||
const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(() =>
|
const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(null);
|
||||||
hasSupabaseConfig ? null : demoOrderGroups[0]?.id ?? null,
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
);
|
|
||||||
const [isLoading, setIsLoading] = React.useState(hasSupabaseConfig);
|
|
||||||
const [loadError, setLoadError] = React.useState("");
|
const [loadError, setLoadError] = React.useState("");
|
||||||
const [isSavingDeliveryChoice, setIsSavingDeliveryChoice] = React.useState(false);
|
const [isSavingDeliveryChoice, setIsSavingDeliveryChoice] = React.useState(false);
|
||||||
|
|
||||||
|
|
@ -46,12 +39,7 @@ export const useOrderGroups = () => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const loadLiveData = async () => {
|
const loadLiveData = async () => {
|
||||||
if (!hasSupabaseConfig) {
|
/* Demo mode removed — always use Supabase */
|
||||||
setOrderGroups(cloneLiveGroups(demoOrderGroups));
|
|
||||||
setIsLoading(false);
|
|
||||||
setLoadError("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setLoadError("");
|
setLoadError("");
|
||||||
|
|
@ -91,7 +79,22 @@ export const useOrderGroups = () => {
|
||||||
}
|
}
|
||||||
}, [orderGroups, selectedOrderGroupId]);
|
}, [orderGroups, selectedOrderGroupId]);
|
||||||
|
|
||||||
const statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS;
|
const statusCounts = React.useMemo(() => {
|
||||||
|
const counts = {};
|
||||||
|
orderGroups.forEach((group) => {
|
||||||
|
const status = getOrderGroupDisplayStatusValue(group);
|
||||||
|
counts[status] = (counts[status] || 0) + 1;
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
|
}, [orderGroups]);
|
||||||
|
|
||||||
|
const statusOptions = React.useMemo(() =>
|
||||||
|
ORDER_GROUP_DISPLAY_STATUS_OPTIONS.map((opt) => ({
|
||||||
|
...opt,
|
||||||
|
count: opt.value === "all" ? orderGroups.length : (statusCounts[opt.value] || 0),
|
||||||
|
})),
|
||||||
|
[statusCounts, orderGroups]
|
||||||
|
);
|
||||||
|
|
||||||
const filteredOrderGroups = React.useMemo(
|
const filteredOrderGroups = React.useMemo(
|
||||||
() => filterOrderGroups(orderGroups, filters),
|
() => filterOrderGroups(orderGroups, filters),
|
||||||
|
|
@ -116,25 +119,7 @@ export const useOrderGroups = () => {
|
||||||
setIsSavingDeliveryChoice(true);
|
setIsSavingDeliveryChoice(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!hasSupabaseConfig) {
|
/* Demo mode removed */
|
||||||
const updatedAt = new Date().toISOString();
|
|
||||||
setOrderGroups((currentGroups) =>
|
|
||||||
currentGroups.map((group) =>
|
|
||||||
group.id === orderGroupId
|
|
||||||
? {
|
|
||||||
...group,
|
|
||||||
deliveryStatus: "agreed",
|
|
||||||
delivery_status: "agreed",
|
|
||||||
deliveryDate,
|
|
||||||
deliveryTime,
|
|
||||||
deliveryHalfDay: deliveryTime,
|
|
||||||
updatedAt,
|
|
||||||
}
|
|
||||||
: group,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await updateOrderGroupDeliveryChoice({
|
const result = await updateOrderGroupDeliveryChoice({
|
||||||
orderGroupId,
|
orderGroupId,
|
||||||
|
|
@ -163,6 +148,58 @@ export const useOrderGroups = () => {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const assignDriver = React.useCallback(async ({ orderGroupId, driverId }) => {
|
||||||
|
setIsSavingDeliveryChoice(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await assignDriverToOrderGroup({ orderGroupId, driverId });
|
||||||
|
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(result.error, "Не удалось назначить водителя"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrderGroups((currentGroups) =>
|
||||||
|
currentGroups.map((group) => (group.id === orderGroupId ? result.data : group)),
|
||||||
|
);
|
||||||
|
return { success: true, data: result.data };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error, "Не удалось назначить водителя"),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
setIsSavingDeliveryChoice(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const changeDeliveryStatus = React.useCallback(async ({ orderGroupId, status }) => {
|
||||||
|
setIsSavingDeliveryChoice(true);
|
||||||
|
try {
|
||||||
|
const result = await updateDeliveryStatus({ orderGroupId, status });
|
||||||
|
if (result.error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(result.error, "Не удалось обновить статус"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
setOrderGroups((currentGroups) =>
|
||||||
|
currentGroups.map((group) => (group.id === orderGroupId ? result.data : group)),
|
||||||
|
);
|
||||||
|
return { success: true, data: result.data };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error, "Не удалось обновить статус"),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
setIsSavingDeliveryChoice(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
orderGroups,
|
orderGroups,
|
||||||
allOrderGroups: orderGroups,
|
allOrderGroups: orderGroups,
|
||||||
|
|
@ -177,6 +214,7 @@ export const useOrderGroups = () => {
|
||||||
orderGroupsByDate,
|
orderGroupsByDate,
|
||||||
deliveryGroupBuckets,
|
deliveryGroupBuckets,
|
||||||
saveManualDeliveryChoice,
|
saveManualDeliveryChoice,
|
||||||
|
assignDriver,
|
||||||
isSavingDeliveryChoice,
|
isSavingDeliveryChoice,
|
||||||
isLoading,
|
isLoading,
|
||||||
loadError,
|
loadError,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Modal } from "../components/UI/Modal";
|
||||||
import { Panel } from "../components/UI/Panel";
|
import { Panel } from "../components/UI/Panel";
|
||||||
import { ProductGuidePanel } from "../components/dashboard/ProductGuidePanel";
|
import { ProductGuidePanel } from "../components/dashboard/ProductGuidePanel";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { fetchDrivers } from "../services/supabase/userRepository";
|
||||||
import { useOrderGroups } from "../hooks/useOrderGroups";
|
import { useOrderGroups } from "../hooks/useOrderGroups";
|
||||||
import { AppShell } from "../layouts/AppShell";
|
import { AppShell } from "../layouts/AppShell";
|
||||||
|
|
||||||
|
|
@ -36,6 +37,7 @@ export const DashboardPage = () => {
|
||||||
const section = ROLE_SECTION[userRole] || ROLE_SECTION.manager;
|
const section = ROLE_SECTION[userRole] || ROLE_SECTION.manager;
|
||||||
const [activeSection, setActiveSection] = React.useState(section.key);
|
const [activeSection, setActiveSection] = React.useState(section.key);
|
||||||
const [isGroupModalOpen, setIsGroupModalOpen] = React.useState(false);
|
const [isGroupModalOpen, setIsGroupModalOpen] = React.useState(false);
|
||||||
|
const [drivers, setDrivers] = React.useState([]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
orderGroups,
|
orderGroups,
|
||||||
|
|
@ -51,12 +53,27 @@ export const DashboardPage = () => {
|
||||||
loadError,
|
loadError,
|
||||||
saveManualDeliveryChoice,
|
saveManualDeliveryChoice,
|
||||||
isSavingDeliveryChoice,
|
isSavingDeliveryChoice,
|
||||||
|
assignDriver,
|
||||||
|
changeDeliveryStatus,
|
||||||
} = useOrderGroups();
|
} = useOrderGroups();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setActiveSection(section.key);
|
setActiveSection(section.key);
|
||||||
}, [section.key]);
|
}, [section.key]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const loadDrivers = async () => {
|
||||||
|
const result = await fetchDrivers();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (result.data) {
|
||||||
|
setDrivers(result.data.filter((u) => u.role === "driver"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadDrivers();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const openGroupModal = React.useCallback((groupId) => {
|
const openGroupModal = React.useCallback((groupId) => {
|
||||||
setSelectedOrderGroupId(groupId);
|
setSelectedOrderGroupId(groupId);
|
||||||
setIsGroupModalOpen(true);
|
setIsGroupModalOpen(true);
|
||||||
|
|
@ -97,7 +114,7 @@ export const DashboardPage = () => {
|
||||||
|
|
||||||
const renderLogisticsWorkspace = () => (
|
const renderLogisticsWorkspace = () => (
|
||||||
<div className="space-y-6 xl:space-y-8">
|
<div className="space-y-6 xl:space-y-8">
|
||||||
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupModal} />
|
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupModal} statusOptions={statusOptions} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -106,6 +123,7 @@ export const DashboardPage = () => {
|
||||||
<DriverDeliveryPlanner
|
<DriverDeliveryPlanner
|
||||||
orderGroups={allOrderGroups}
|
orderGroups={allOrderGroups}
|
||||||
onOpenOrder={openGroupModal}
|
onOpenOrder={openGroupModal}
|
||||||
|
currentUser={user}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -150,7 +168,7 @@ export const DashboardPage = () => {
|
||||||
|
|
||||||
{renderActiveSection()}
|
{renderActiveSection()}
|
||||||
|
|
||||||
<Modal isOpen={isGroupModalOpen} onClose={() => setIsGroupModalOpen(false)}>
|
<Modal isOpen={isGroupModalOpen} onClose={() => setIsGroupModalOpen(false)} className="md:max-w-[800px]">
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -170,6 +188,10 @@ export const DashboardPage = () => {
|
||||||
canManageDelivery={["manager", "logistician", "admin"].includes(userRole)}
|
canManageDelivery={["manager", "logistician", "admin"].includes(userRole)}
|
||||||
onSaveManualDeliveryChoice={saveManualDeliveryChoice}
|
onSaveManualDeliveryChoice={saveManualDeliveryChoice}
|
||||||
isSavingDeliveryChoice={isSavingDeliveryChoice}
|
isSavingDeliveryChoice={isSavingDeliveryChoice}
|
||||||
|
drivers={drivers}
|
||||||
|
onAssignDriver={assignDriver}
|
||||||
|
onChangeDeliveryStatus={changeDeliveryStatus}
|
||||||
|
userRole={userRole}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const baseGroup = {
|
||||||
status: "ready_for_notification",
|
status: "ready_for_notification",
|
||||||
deliveryStatus: "agreed",
|
deliveryStatus: "agreed",
|
||||||
delivery_status: "agreed",
|
delivery_status: "agreed",
|
||||||
|
assignedDriverId: "u-driver",
|
||||||
deliveryDate: "2026-04-16",
|
deliveryDate: "2026-04-16",
|
||||||
deliveryTime: "Первая половина дня",
|
deliveryTime: "Первая половина дня",
|
||||||
updatedAt: "2026-04-15T09:00:00Z",
|
updatedAt: "2026-04-15T09:00:00Z",
|
||||||
|
|
@ -76,6 +77,7 @@ const mockOrderGroupsState = {
|
||||||
loadError: "",
|
loadError: "",
|
||||||
saveManualDeliveryChoice: vi.fn(),
|
saveManualDeliveryChoice: vi.fn(),
|
||||||
isSavingDeliveryChoice: false,
|
isSavingDeliveryChoice: false,
|
||||||
|
assignDriver: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("DashboardPage", () => {
|
describe("DashboardPage", () => {
|
||||||
|
|
@ -130,7 +132,7 @@ describe("DashboardPage", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("Наборы доставки");
|
expect(markup).toContain("Наборы доставки");
|
||||||
expect(markup).toContain("Готовы к уведомлению");
|
expect(markup).toContain("Согласовано");
|
||||||
expect(markup).not.toContain("Управление ботами");
|
expect(markup).not.toContain("Управление ботами");
|
||||||
expect(markup).not.toContain("рабочая панель");
|
expect(markup).not.toContain("рабочая панель");
|
||||||
expect(markup).not.toContain("Сегодня");
|
expect(markup).not.toContain("Сегодня");
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ export const LoginPage = () => {
|
||||||
error={displayError}
|
error={displayError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(isDemoMode || import.meta.env.DEV) ? (
|
{(isDemoMode || import.meta.env.DEV === true && import.meta.env.VITE_ENABLE_DEMO === 'true') ? (
|
||||||
<div className="w-full max-w-md space-y-3">
|
<div className="w-full max-w-md space-y-3">
|
||||||
<p className="text-center text-sm text-[var(--color-text-muted)]">
|
<p className="text-center text-sm text-[var(--color-text-muted)]">
|
||||||
{isDemoMode ? "Демо-режим — войдите под любой ролью" : "Быстрый вход (только для разработки)"}
|
{isDemoMode ? "Демо-режим — войдите под любой ролью" : "Быстрый вход (только для разработки)"}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const getDeliveryDate = (group) => normalizeDate(group.deliveryDate || group.cus
|
||||||
|
|
||||||
export const DELIVERY_GROUP_STATUS_LABELS = {
|
export const DELIVERY_GROUP_STATUS_LABELS = {
|
||||||
pending_confirmation: "Ожидает согласования",
|
pending_confirmation: "Ожидает согласования",
|
||||||
|
manual_confirmation_required: "Требуется ручное подтверждение",
|
||||||
agreed: "Согласовано",
|
agreed: "Согласовано",
|
||||||
driver_assigned: "Назначен водитель",
|
driver_assigned: "Назначен водитель",
|
||||||
loaded: "Загружено",
|
loaded: "Загружено",
|
||||||
|
|
@ -13,6 +14,16 @@ export const DELIVERY_GROUP_STATUS_LABELS = {
|
||||||
cancelled: "Отменено",
|
cancelled: "Отменено",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const NOTIFICATION_STATUS_LABELS = {
|
||||||
|
not_started: "",
|
||||||
|
link_ready: "Ссылка готова",
|
||||||
|
first_sms_sent: "1-е приглашение отправлено",
|
||||||
|
second_sms_sent: "2-е приглашение отправлено",
|
||||||
|
send_failed: "Ошибка отправки",
|
||||||
|
confirmed: "Согласовано",
|
||||||
|
manual_required: "Переведено в ручное",
|
||||||
|
};
|
||||||
|
|
||||||
export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
|
export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
|
||||||
"agreed",
|
"agreed",
|
||||||
"driver_assigned",
|
"driver_assigned",
|
||||||
|
|
@ -46,7 +57,7 @@ const normalizeDeliveryHalfDayLabel = (value) => {
|
||||||
return HALF_DAY_LABELS.afternoon;
|
return HALF_DAY_LABELS.afternoon;
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseJsonIfNeeded = (value) => {
|
const parseJsonIfNeeded = (value) => {
|
||||||
|
|
@ -133,13 +144,19 @@ export const getOrderGroupDisplayStatusLabel = (group) => {
|
||||||
return getOrderGroupDeliveryStatusLabel(deliveryStatus);
|
return getOrderGroupDeliveryStatusLabel(deliveryStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
||||||
|
const notificationLabel = NOTIFICATION_STATUS_LABELS[notificationStatus];
|
||||||
|
if (notificationLabel && notificationStatus !== "link_ready" && notificationStatus !== "not_started") {
|
||||||
|
return notificationLabel;
|
||||||
|
}
|
||||||
|
|
||||||
return getOrderGroupStatusLabel(group?.status);
|
return getOrderGroupStatusLabel(group?.status);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getOrderGroupDisplayStatusValue = (group) => {
|
export const getOrderGroupDisplayStatusValue = (group) => {
|
||||||
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
||||||
|
|
||||||
if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
|
if (deliveryStatus) {
|
||||||
return `delivery:${deliveryStatus}`;
|
return `delivery:${deliveryStatus}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -269,11 +286,13 @@ export const ORDER_GROUP_STATUS_LABELS = {
|
||||||
ready_for_notification: "Готово к уведомлению",
|
ready_for_notification: "Готово к уведомлению",
|
||||||
sms_sent: "SMS отправлены",
|
sms_sent: "SMS отправлены",
|
||||||
manual_work: "Нужна ручная работа",
|
manual_work: "Нужна ручная работа",
|
||||||
|
ready_to_launch: "Готово к запуску",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [
|
export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [
|
||||||
{ value: "all", label: "Все статусы" },
|
{ value: "all", label: "Все статусы" },
|
||||||
{ value: "status:ready_for_notification", label: ORDER_GROUP_STATUS_LABELS.ready_for_notification },
|
{ value: "delivery:pending_confirmation", label: DELIVERY_GROUP_STATUS_LABELS.pending_confirmation },
|
||||||
|
{ value: "delivery:manual_confirmation_required", label: DELIVERY_GROUP_STATUS_LABELS.manual_confirmation_required },
|
||||||
{ value: "delivery:agreed", label: DELIVERY_GROUP_STATUS_LABELS.agreed },
|
{ value: "delivery:agreed", label: DELIVERY_GROUP_STATUS_LABELS.agreed },
|
||||||
{ value: "delivery:driver_assigned", label: DELIVERY_GROUP_STATUS_LABELS.driver_assigned },
|
{ value: "delivery:driver_assigned", label: DELIVERY_GROUP_STATUS_LABELS.driver_assigned },
|
||||||
{ value: "delivery:loaded", label: DELIVERY_GROUP_STATUS_LABELS.loaded },
|
{ value: "delivery:loaded", label: DELIVERY_GROUP_STATUS_LABELS.loaded },
|
||||||
|
|
@ -281,31 +300,34 @@ export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [
|
||||||
{ value: "delivery:delivered", label: DELIVERY_GROUP_STATUS_LABELS.delivered },
|
{ value: "delivery:delivered", label: DELIVERY_GROUP_STATUS_LABELS.delivered },
|
||||||
{ value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem },
|
{ value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem },
|
||||||
{ value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled },
|
{ value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled },
|
||||||
{ value: "status:sms_sent", label: ORDER_GROUP_STATUS_LABELS.sms_sent },
|
|
||||||
{ value: "status:manual_work", label: ORDER_GROUP_STATUS_LABELS.manual_work },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const getOrderGroupStatusLabel = (status) =>
|
export const getOrderGroupStatusLabel = (status) =>
|
||||||
ORDER_GROUP_STATUS_LABELS[status] || status || "Неизвестно";
|
ORDER_GROUP_STATUS_LABELS[status] || status || "Неизвестно";
|
||||||
|
|
||||||
export const getOrderGroupDeliveryStatusTone = (status) => {
|
export const getOrderGroupDeliveryStatusTone = (status) => {
|
||||||
if (status === "agreed") {
|
switch (status) {
|
||||||
return "accent";
|
case "pending_confirmation":
|
||||||
|
return "neutral";
|
||||||
|
case "manual_confirmation_required":
|
||||||
|
return "warning";
|
||||||
|
case "agreed":
|
||||||
|
return "accent";
|
||||||
|
case "driver_assigned":
|
||||||
|
return "info";
|
||||||
|
case "loaded":
|
||||||
|
return "info";
|
||||||
|
case "on_route":
|
||||||
|
return "accent";
|
||||||
|
case "delivered":
|
||||||
|
return "accent";
|
||||||
|
case "problem":
|
||||||
|
return "danger";
|
||||||
|
case "cancelled":
|
||||||
|
return "danger";
|
||||||
|
default:
|
||||||
|
return "neutral";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "problem") {
|
|
||||||
return "warning";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "delivered") {
|
|
||||||
return "accent";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "cancelled") {
|
|
||||||
return "danger";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "neutral";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const groupOrderGroupsByDate = (groups) => {
|
export const groupOrderGroupsByDate = (groups) => {
|
||||||
|
|
@ -343,6 +365,12 @@ export const groupOrderGroupsByDate = (groups) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBucketKey = (group) => {
|
const getBucketKey = (group) => {
|
||||||
|
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
||||||
|
|
||||||
|
if (notificationStatus === "manual_required") {
|
||||||
|
return "manual_work";
|
||||||
|
}
|
||||||
|
|
||||||
if (group.smsSentAt) {
|
if (group.smsSentAt) {
|
||||||
return "sms_sent";
|
return "sms_sent";
|
||||||
}
|
}
|
||||||
|
|
@ -397,6 +425,14 @@ export const getOrderGroupStatusTone = (group) => {
|
||||||
return getOrderGroupDeliveryStatusTone(deliveryStatus);
|
return getOrderGroupDeliveryStatusTone(deliveryStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
||||||
|
if (notificationStatus === "send_failed" || notificationStatus === "manual_required") {
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
if (notificationStatus === "first_sms_sent" || notificationStatus === "second_sms_sent") {
|
||||||
|
return "accent";
|
||||||
|
}
|
||||||
|
|
||||||
if (group.smsSentAt) {
|
if (group.smsSentAt) {
|
||||||
return "accent";
|
return "accent";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { safeSupabaseCall } from "../safeSupabaseCall";
|
import { safeSupabaseCall } from "../safeSupabaseCall";
|
||||||
|
import logger from "../../utils/logger";
|
||||||
import { hasSupabaseConfig, supabase } from "../../supabaseClient";
|
import { hasSupabaseConfig, supabase } from "../../supabaseClient";
|
||||||
import {
|
import {
|
||||||
getOrderGroupDeliveryHalfDay,
|
getOrderGroupDeliveryHalfDay,
|
||||||
|
|
@ -77,7 +78,16 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
: ALLOWED_DELIVERY_TIMES.has(rawDeliveryHalfDay)
|
: ALLOWED_DELIVERY_TIMES.has(rawDeliveryHalfDay)
|
||||||
? rawDeliveryHalfDay
|
? rawDeliveryHalfDay
|
||||||
: "";
|
: "";
|
||||||
const deliveryAddress = normalizeText(row.delivery_address);
|
|
||||||
|
const extractAddressFromSourceOrders = (sourceOrders) => {
|
||||||
|
if (!Array.isArray(sourceOrders) || !sourceOrders.length) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const first = sourceOrders[0];
|
||||||
|
return normalizeText(first.adress || first.address || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const deliveryAddress = normalizeText(row.delivery_address) || extractAddressFromSourceOrders(row.source_orders);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|
@ -96,12 +106,18 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
customerPhoneNormalized: parsedKey.phone || normalizePhone(customerPhone),
|
customerPhoneNormalized: parsedKey.phone || normalizePhone(customerPhone),
|
||||||
customerDate,
|
customerDate,
|
||||||
deliveryAddress,
|
deliveryAddress,
|
||||||
|
assignedDriverId: row.assigned_driver_id || null,
|
||||||
|
assignedDriverName: row.assigned_driver?.name || "",
|
||||||
ordersCount,
|
ordersCount,
|
||||||
readyCount,
|
readyCount,
|
||||||
notReadyCount,
|
notReadyCount,
|
||||||
orderNumbers,
|
orderNumbers,
|
||||||
status: row.status || "draft",
|
status: row.status || "draft",
|
||||||
smsSentAt: row.sms_sent_at || null,
|
smsSentAt: row.sms_sent_at || null,
|
||||||
|
firstSmsSentAt: row.first_sms_sent_at || null,
|
||||||
|
secondSmsSentAt: row.second_sms_sent_at || null,
|
||||||
|
manualConfirmationAt: row.manual_confirmation_at || null,
|
||||||
|
notificationStatus: normalizeText(row.notification_status),
|
||||||
createdFromExchangeAt: row.created_from_exchange_at || null,
|
createdFromExchangeAt: row.created_from_exchange_at || null,
|
||||||
sourceKey: row.source_key || null,
|
sourceKey: row.source_key || null,
|
||||||
legacyCustomerName: row.legacy_customer_name || null,
|
legacyCustomerName: row.legacy_customer_name || null,
|
||||||
|
|
@ -148,6 +164,8 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
sourceOrders: row.source_orders,
|
sourceOrders: row.source_orders,
|
||||||
}),
|
}),
|
||||||
getOrderGroupDeliveryStatusLabel(deliveryStatus),
|
getOrderGroupDeliveryStatusLabel(deliveryStatus),
|
||||||
|
row.notification_status,
|
||||||
|
extractAddressFromSourceOrders(row.source_orders),
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")
|
.join(" ")
|
||||||
|
|
@ -162,7 +180,7 @@ export const updateOrderGroupDeliveryChoice = async ({
|
||||||
}) => {
|
}) => {
|
||||||
return safeSupabaseCall(async () => {
|
return safeSupabaseCall(async () => {
|
||||||
const client = requireSupabase();
|
const client = requireSupabase();
|
||||||
const { data, error } = await client
|
const updateResult = await client
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
.update({
|
.update({
|
||||||
delivery_status: "agreed",
|
delivery_status: "agreed",
|
||||||
|
|
@ -171,8 +189,16 @@ export const updateOrderGroupDeliveryChoice = async ({
|
||||||
notification_status: "confirmed",
|
notification_status: "confirmed",
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
.eq("id", orderGroupId);
|
||||||
|
|
||||||
|
if (updateResult.error) {
|
||||||
|
throw updateResult.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await client
|
||||||
|
.from("order_groups")
|
||||||
|
.select("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, manual_confirmation_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)")
|
||||||
.eq("id", orderGroupId)
|
.eq("id", orderGroupId)
|
||||||
.select("*")
|
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
@ -183,18 +209,97 @@ export const updateOrderGroupDeliveryChoice = async ({
|
||||||
}, "Ошибка сохранения согласования доставки");
|
}, "Ошибка сохранения согласования доставки");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const assignDriverToOrderGroup = async ({
|
||||||
|
orderGroupId,
|
||||||
|
driverId,
|
||||||
|
}) => {
|
||||||
|
return safeSupabaseCall(async () => {
|
||||||
|
const client = requireSupabase();
|
||||||
|
|
||||||
|
logger.debug("[assignDriver] orderGroupId:", orderGroupId, "driverId:", driverId);
|
||||||
|
|
||||||
|
// Use RPC to bypass RLS on order_groups update
|
||||||
|
const { data: rpcData, error: rpcError } = await client.rpc("assign_driver", {
|
||||||
|
p_order_group_id: orderGroupId,
|
||||||
|
p_driver_id: driverId || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug("[assignDriver] rpc result:", { rpcData, rpcError });
|
||||||
|
if (rpcError) {
|
||||||
|
throw rpcError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rpcData) {
|
||||||
|
throw new Error("Группа не найдена");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch with driver join for the mapper
|
||||||
|
const { data, error } = await client
|
||||||
|
.from("order_groups")
|
||||||
|
.select("*, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)")
|
||||||
|
.eq("id", orderGroupId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapOrderGroupRowToDeliveryGroup(data);
|
||||||
|
}, "Ошибка назначения водителя");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateDeliveryStatus = async ({ orderGroupId, status }) => {
|
||||||
|
return safeSupabaseCall(async () => {
|
||||||
|
const client = requireSupabase();
|
||||||
|
const { data: rpcData, error: rpcError } = await client.rpc("update_delivery_status", {
|
||||||
|
p_order_group_id: orderGroupId,
|
||||||
|
p_status: status,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rpcError) {
|
||||||
|
throw rpcError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch updated group
|
||||||
|
const { data, error } = await client
|
||||||
|
.from("order_groups")
|
||||||
|
.select("*, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)")
|
||||||
|
.eq("id", orderGroupId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapOrderGroupRowToDeliveryGroup(data);
|
||||||
|
}, "Ошибка обновления статуса доставки");
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchOrderGroups = async () => {
|
export const fetchOrderGroups = async () => {
|
||||||
return safeSupabaseCall(async () => {
|
return safeSupabaseCall(async () => {
|
||||||
const client = requireSupabase();
|
const client = requireSupabase();
|
||||||
const { data, error } = await client
|
const { data, error } = await client
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
.select("*")
|
.select("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, manual_confirmation_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)")
|
||||||
.order("updated_at", { ascending: false });
|
.order("updated_at", { ascending: false });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (data || []).map(mapOrderGroupRowToDeliveryGroup).filter(Boolean);
|
// Load driver names to patch groups where assigned_driver join is missing
|
||||||
|
const { data: drivers, error: driversError } = await client.rpc("get_drivers");
|
||||||
|
const driverMap = new Map();
|
||||||
|
if (!driversError && drivers) {
|
||||||
|
drivers.forEach((d) => driverMap.set(d.id, d.name || d.email));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (data || []).map((row) => {
|
||||||
|
const group = mapOrderGroupRowToDeliveryGroup(row);
|
||||||
|
if (group && group.assignedDriverId && !group.assignedDriverName) {
|
||||||
|
group.assignedDriverName = driverMap.get(group.assignedDriverId) || "";
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}).filter(Boolean);
|
||||||
}, "Ошибка загрузки групп доставки");
|
}, "Ошибка загрузки групп доставки");
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -116,10 +116,13 @@ describe("updateOrderGroupDeliveryChoice", () => {
|
||||||
selectMock.mockReset();
|
selectMock.mockReset();
|
||||||
singleMock.mockReset();
|
singleMock.mockReset();
|
||||||
|
|
||||||
fromMock.mockReturnValue({ update: updateMock });
|
fromMock
|
||||||
|
.mockReturnValueOnce({ update: updateMock })
|
||||||
|
.mockReturnValueOnce({ select: selectMock });
|
||||||
updateMock.mockReturnValue({ eq: eqMock });
|
updateMock.mockReturnValue({ eq: eqMock });
|
||||||
eqMock.mockReturnValue({ select: selectMock });
|
eqMock.mockReturnValueOnce({ error: null, status: 200, statusText: "OK" })
|
||||||
selectMock.mockReturnValue({ single: singleMock });
|
.mockReturnValueOnce({ single: singleMock });
|
||||||
|
selectMock.mockReturnValue({ eq: eqMock });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the group directly in order_groups", async () => {
|
it("updates the group directly in order_groups", async () => {
|
||||||
|
|
@ -163,7 +166,7 @@ describe("updateOrderGroupDeliveryChoice", () => {
|
||||||
updated_at: expect.any(String),
|
updated_at: expect.any(String),
|
||||||
});
|
});
|
||||||
expect(eqMock).toHaveBeenCalledWith("id", "group-id");
|
expect(eqMock).toHaveBeenCalledWith("id", "group-id");
|
||||||
expect(selectMock).toHaveBeenCalledWith("*");
|
expect(selectMock).toHaveBeenCalledWith("id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, delivery_invitation_id, delivery_link, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, manual_confirmation_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name)");
|
||||||
expect(singleMock).toHaveBeenCalledTimes(1);
|
expect(singleMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ export const fetchOrders = async () => {
|
||||||
const client = requireSupabase();
|
const client = requireSupabase();
|
||||||
const { data, error } = await client
|
const { data, error } = await client
|
||||||
.from("orders")
|
.from("orders")
|
||||||
.select("*, order_history(*), delivery_slots(*), chat_messages(*), order_logisticians(*)")
|
.select("id, order_number, customer, status, delivery_agreement_status, manager_id, logistician_id, assigned_driver_id, ready_for_delivery_at, delivery_flow_started_at, delivery_flow_source, source_order_number, source_order_date, source_customer_name, source_customer_phone, source_customer_email, source_customer_city, source_total_sum, source_paid_at, source_gateway, source_associated_bills_text, source_production_at, source_saw_at, source_glue_at, source_h_glue_at, source_curve_at, source_accept_at, source_ship_at, source_sms_legacy_at, source_payload, delivery_set_key, delivery_set_name, delivery_set_status, delivery_set_ready_at, delivery_ready_reason, created_at, updated_at, order_history(id, action, old_status, new_status, user_id, user_name, metadata, created_at), delivery_slots(id, delivery_date, delivery_time, logistician_id, logistician_name, status, created_at, selected_by_client_at), chat_messages(id, sender_type, sender_name, channel, text, external_message_id, payload, created_at), order_logisticians(order_id, logistician_id)")
|
||||||
.order("updated_at", { ascending: false });
|
.order("updated_at", { ascending: false });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { safeSupabaseCall } from "../safeSupabaseCall";
|
import { safeSupabaseCall } from "../safeSupabaseCall";
|
||||||
|
import logger from "../../utils/logger";
|
||||||
import { hasSupabaseConfig, supabase } from "../../supabaseClient";
|
import { hasSupabaseConfig, supabase } from "../../supabaseClient";
|
||||||
|
|
||||||
const requireSupabase = () => {
|
const requireSupabase = () => {
|
||||||
|
|
@ -41,3 +42,28 @@ export const fetchUsers = async () => {
|
||||||
return (data || []).map(mapUserRowToAppUser).filter(Boolean);
|
return (data || []).map(mapUserRowToAppUser).filter(Boolean);
|
||||||
}, "Ошибка загрузки пользователей");
|
}, "Ошибка загрузки пользователей");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchDrivers = async () => {
|
||||||
|
return safeSupabaseCall(async () => {
|
||||||
|
const client = requireSupabase();
|
||||||
|
const { data, error } = await client.rpc("get_drivers");
|
||||||
|
|
||||||
|
logger.debug("[fetchDrivers] rpc raw:", { data, error });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = (data || []).map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
name: row.name,
|
||||||
|
role: "driver",
|
||||||
|
lastLogin: null,
|
||||||
|
botBindings: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.debug("[fetchDrivers] mapped:", mapped);
|
||||||
|
return mapped;
|
||||||
|
}, "Ошибка загрузки водителей");
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
|
const isDev = typeof import.meta !== "undefined" ? import.meta.env.DEV : true;
|
||||||
|
|
||||||
const logger = {
|
const logger = {
|
||||||
info: (message, payload) => console.info(`[info] ${message}`, payload ?? ""),
|
info: (message, payload) => console.info(`[info] ${message}`, payload ?? ""),
|
||||||
error: (message, error) => console.error(`[error] ${message}`, error ?? ""),
|
error: (message, error) => console.error(`[error] ${message}`, error ?? ""),
|
||||||
order: (message, payload) => console.log(`[order] ${message}`, payload ?? ""),
|
order: (message, payload) => console.log(`[order] ${message}`, payload ?? ""),
|
||||||
|
debug: isDev
|
||||||
|
? (message, payload) => console.debug(`[debug] ${message}`, payload ?? "")
|
||||||
|
: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default logger;
|
export default logger;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
v2.99.0
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.8";
|
import { createClient } from "@supabase/supabase-js";
|
||||||
import { getOrderUpdateForInboundAction } from "./workflow.ts";
|
import { getOrderUpdateForInboundAction } from "./workflow.ts";
|
||||||
|
|
||||||
export type ProviderName = "telegram" | "vk" | "messenger_max";
|
export type ProviderName = "telegram" | "vk" | "messenger_max";
|
||||||
|
|
@ -19,6 +19,13 @@ export const createServiceClient = () => {
|
||||||
return createClient(supabaseUrl, serviceRoleKey);
|
return createClient(supabaseUrl, serviceRoleKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Create a Supabase client that respects RLS policies (uses anon key). */
|
||||||
|
export const createAnonClient = () => {
|
||||||
|
const supabaseUrl = Deno.env.get("SUPABASE_URL") || "";
|
||||||
|
const anonKey = Deno.env.get("SUPABASE_ANON_KEY") || "";
|
||||||
|
return createClient(supabaseUrl, anonKey);
|
||||||
|
};
|
||||||
|
|
||||||
export const json = (body: unknown, status = 200) =>
|
export const json = (body: unknown, status = 200) =>
|
||||||
new Response(JSON.stringify(body), {
|
new Response(JSON.stringify(body), {
|
||||||
status,
|
status,
|
||||||
|
|
|
||||||
|
|
@ -104,12 +104,7 @@ const resolveAllowedOrigins = (mode: CorsMode) => {
|
||||||
return Array.from(new Set(configured));
|
return Array.from(new Set(configured));
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentMode = readEnv("NODE_ENV") || "development";
|
return [];
|
||||||
if (currentMode === "production") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...DEFAULT_LOCAL_ORIGINS];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
|
|
@ -341,6 +336,40 @@ export const maskOrderNumber = (orderNumber: string | null | undefined) => {
|
||||||
return `…${value.slice(-4)}`;
|
return `…${value.slice(-4)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const isValidUuid = (value: string): boolean => {
|
||||||
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireUuid = (value: string | undefined | null, label = "id"): string => {
|
||||||
|
const trimmed = (value || "").trim();
|
||||||
|
if (!trimmed || !isValidUuid(trimmed)) {
|
||||||
|
throw new HttpError(400, `Invalid ${label} format`);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const requireSameOrigin = (request: Request, allowedOrigins: string[]) => {
|
||||||
|
const origin = request.headers.get("origin") || "";
|
||||||
|
const host = request.headers.get("host") || "";
|
||||||
|
if (!origin || !host) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const originHost = new URL(origin).host;
|
||||||
|
return allowedOrigins.some((allowed) => {
|
||||||
|
try {
|
||||||
|
return new URL(allowed).host === originHost;
|
||||||
|
} catch {
|
||||||
|
return allowed === origin;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
export const requireRateLimit = async (
|
export const requireRateLimit = async (
|
||||||
supabase: {
|
supabase: {
|
||||||
rpc: (
|
rpc: (
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
isActiveInvitationState,
|
isActiveInvitationState,
|
||||||
isInvitationExpired,
|
isInvitationExpired,
|
||||||
} from "../_shared/delivery-invitations.ts";
|
} from "../_shared/delivery-invitations.ts";
|
||||||
|
import { isValidUuid, requireUuid } from "../_shared/security.ts";
|
||||||
import { createServiceClient } from "../_shared/chatbot.ts";
|
import { createServiceClient } from "../_shared/chatbot.ts";
|
||||||
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
||||||
import {
|
import {
|
||||||
|
|
@ -14,6 +15,7 @@ import {
|
||||||
preflightResponse,
|
preflightResponse,
|
||||||
readJsonBody,
|
readJsonBody,
|
||||||
requireRateLimit,
|
requireRateLimit,
|
||||||
|
requireSameOrigin,
|
||||||
} from "../_shared/security.ts";
|
} from "../_shared/security.ts";
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 8 * 1024;
|
const MAX_BODY_BYTES = 8 * 1024;
|
||||||
|
|
@ -65,6 +67,19 @@ Deno.serve(async (request) => {
|
||||||
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
|
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allowedOriginsForCsrf = ((): string[] => {
|
||||||
|
const envOrigins = (Deno.env.get("APP_ALLOWED_ORIGINS") || "").split(",").map((s: string) => s.trim()).filter(Boolean);
|
||||||
|
const appUrl = Deno.env.get("PUBLIC_APP_URL") || Deno.env.get("APP_PUBLIC_URL") || "";
|
||||||
|
return [...envOrigins, appUrl].filter(Boolean);
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!requireSameOrigin(request, allowedOriginsForCsrf)) {
|
||||||
|
const origin = request.headers.get("origin") || "";
|
||||||
|
if (origin) {
|
||||||
|
return jsonResponse({ ok: false, error: "Cross-origin request not allowed" }, 403, corsHeaders);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { body } = await readJsonBody<ConfirmBody>(request, {
|
const { body } = await readJsonBody<ConfirmBody>(request, {
|
||||||
maxBytes: MAX_BODY_BYTES,
|
maxBytes: MAX_BODY_BYTES,
|
||||||
|
|
@ -74,6 +89,14 @@ Deno.serve(async (request) => {
|
||||||
return jsonResponse({ ok: false, error: "token is required" }, 400, corsHeaders);
|
return jsonResponse({ ok: false, error: "token is required" }, 400, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.orderGroupId) {
|
||||||
|
try {
|
||||||
|
requireUuid(body.orderGroupId, "orderGroupId");
|
||||||
|
} catch (e) {
|
||||||
|
return jsonResponse({ ok: false, error: (e as Error).message }, 400, corsHeaders);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const tokenHash = await hashInvitationToken(body.token);
|
const tokenHash = await hashInvitationToken(body.token);
|
||||||
const supabase = createServiceClient();
|
const supabase = createServiceClient();
|
||||||
const ipHash = await hashText(getClientIp(request));
|
const ipHash = await hashText(getClientIp(request));
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
} from "../_shared/security.ts";
|
} from "../_shared/security.ts";
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 16 * 1024;
|
const MAX_BODY_BYTES = 16 * 1024;
|
||||||
|
const MAX_SLOTS = 14;
|
||||||
|
|
||||||
type CreateInvitationBody = {
|
type CreateInvitationBody = {
|
||||||
orderId?: string;
|
orderId?: string;
|
||||||
|
|
@ -150,7 +151,7 @@ const createOrderGroupInvitation = async ({
|
||||||
const publicBaseUrl = resolveRequiredPublicAppUrl(request);
|
const publicBaseUrl = resolveRequiredPublicAppUrl(request);
|
||||||
const url = buildInvitationUrl(publicBaseUrl, token);
|
const url = buildInvitationUrl(publicBaseUrl, token);
|
||||||
const availableSlots = body.availableSlots?.length
|
const availableSlots = body.availableSlots?.length
|
||||||
? normalizeAvailableSlots(body.availableSlots)
|
? normalizeAvailableSlots(body.availableSlots).slice(0, MAX_SLOTS)
|
||||||
: buildDefaultDatedAvailableSlots();
|
: buildDefaultDatedAvailableSlots();
|
||||||
|
|
||||||
const invitationPayload = {
|
const invitationPayload = {
|
||||||
|
|
@ -163,7 +164,7 @@ const createOrderGroupInvitation = async ({
|
||||||
customer_phone: customerPhone,
|
customer_phone: customerPhone,
|
||||||
customer_messenger: body.customerMessenger || null,
|
customer_messenger: body.customerMessenger || null,
|
||||||
available_slots: availableSlots,
|
available_slots: availableSlots,
|
||||||
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
expires_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
sent_at: null,
|
sent_at: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -321,7 +322,7 @@ Deno.serve(async (request) => {
|
||||||
customer_phone: body.customerPhone || null,
|
customer_phone: body.customerPhone || null,
|
||||||
customer_messenger: body.customerMessenger || null,
|
customer_messenger: body.customerMessenger || null,
|
||||||
available_slots: normalizeAvailableSlots(body.availableSlots),
|
available_slots: normalizeAvailableSlots(body.availableSlots),
|
||||||
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
expires_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
sent_at: new Date().toISOString(),
|
sent_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
isInvitationExpired,
|
isInvitationExpired,
|
||||||
} from "../_shared/delivery-invitations.ts";
|
} from "../_shared/delivery-invitations.ts";
|
||||||
import { createServiceClient } from "../_shared/chatbot.ts";
|
import { createServiceClient } from "../_shared/chatbot.ts";
|
||||||
|
import { isValidUuid } from "../_shared/security.ts";
|
||||||
import {
|
import {
|
||||||
getClientIp,
|
getClientIp,
|
||||||
getCorsHeaders,
|
getCorsHeaders,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.49.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import {
|
import { getOrderUpdateForDeliveryInvitationAction } from "../_shared/delivery-invitations.ts";
|
||||||
getOrderUpdateForDeliveryInvitationAction,
|
import { requireUuid } from "../_shared/security.ts";
|
||||||
} from "../_shared/delivery-invitations.ts";
|
|
||||||
import { createServiceClient } from "../_shared/chatbot.ts";
|
import { createServiceClient } from "../_shared/chatbot.ts";
|
||||||
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
||||||
import {
|
import {
|
||||||
|
|
@ -42,6 +41,12 @@ Deno.serve(async (request) => {
|
||||||
return jsonResponse({ error: "orderId is required" }, 400, corsHeaders);
|
return jsonResponse({ error: "orderId is required" }, 400, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
requireUuid(body.orderId, "orderId");
|
||||||
|
} catch (e) {
|
||||||
|
return jsonResponse({ ok: false, error: (e as Error).message }, 400, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = createServiceClient();
|
const supabase = createServiceClient();
|
||||||
await requireRateLimit(supabase, {
|
await requireRateLimit(supabase, {
|
||||||
scope: "delivery-report",
|
scope: "delivery-report",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createServiceClient } from "../_shared/chatbot.ts";
|
import { createAnonClient } from "../_shared/chatbot.ts";
|
||||||
import {
|
import {
|
||||||
getClientIp,
|
getClientIp,
|
||||||
getCorsHeaders,
|
getCorsHeaders,
|
||||||
|
|
@ -38,7 +38,7 @@ Deno.serve(async (request) => {
|
||||||
return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders);
|
return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = createServiceClient();
|
const supabase = createAnonClient();
|
||||||
const emailHash = await hashText(email);
|
const emailHash = await hashText(email);
|
||||||
const ipHash = await hashText(getClientIp(request));
|
const ipHash = await hashText(getClientIp(request));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,12 @@ Deno.serve(async (request) => {
|
||||||
return jsonResponse({ error: "orderId is required" }, 400, corsHeaders);
|
return jsonResponse({ error: "orderId is required" }, 400, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
requireUuid(body.orderId, "orderId");
|
||||||
|
} catch (e) {
|
||||||
|
return jsonResponse({ ok: false, error: (e as Error).message }, 400, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = createServiceClient();
|
const supabase = createServiceClient();
|
||||||
await requireRateLimit(supabase, {
|
await requireRateLimit(supabase, {
|
||||||
scope: "delivery-transfer",
|
scope: "delivery-transfer",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createServiceClient } from "../_shared/chatbot.ts";
|
import { createAnonClient } from "../_shared/chatbot.ts";
|
||||||
import {
|
import {
|
||||||
getClientIp,
|
getClientIp,
|
||||||
getCorsHeaders,
|
getCorsHeaders,
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
preflightResponse,
|
preflightResponse,
|
||||||
readJsonBody,
|
readJsonBody,
|
||||||
requireRateLimit,
|
requireRateLimit,
|
||||||
|
requireSameOrigin,
|
||||||
} from "../_shared/security.ts";
|
} from "../_shared/security.ts";
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 8 * 1024;
|
const MAX_BODY_BYTES = 8 * 1024;
|
||||||
|
|
@ -28,6 +29,19 @@ Deno.serve(async (request) => {
|
||||||
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
|
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allowedOriginsForCsrf = ((): string[] => {
|
||||||
|
const envOrigins = (Deno.env.get("APP_ALLOWED_ORIGINS") || "").split(",").map((s: string) => s.trim()).filter(Boolean);
|
||||||
|
const appUrl = Deno.env.get("PUBLIC_APP_URL") || Deno.env.get("APP_PUBLIC_URL") || "";
|
||||||
|
return [...envOrigins, appUrl].filter(Boolean);
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!requireSameOrigin(request, allowedOriginsForCsrf)) {
|
||||||
|
const origin = request.headers.get("origin") || "";
|
||||||
|
if (origin) {
|
||||||
|
return jsonResponse({ ok: false, error: "Cross-origin request not allowed" }, 403, corsHeaders);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { body } = await readJsonBody<{ email?: string; otp?: string }>(request, {
|
const { body } = await readJsonBody<{ email?: string; otp?: string }>(request, {
|
||||||
maxBytes: MAX_BODY_BYTES,
|
maxBytes: MAX_BODY_BYTES,
|
||||||
|
|
@ -43,7 +57,7 @@ Deno.serve(async (request) => {
|
||||||
return jsonResponse({ ok: false, error: "Valid OTP is required" }, 400, corsHeaders);
|
return jsonResponse({ ok: false, error: "Valid OTP is required" }, 400, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = createServiceClient();
|
const supabase = createAnonClient();
|
||||||
const emailHash = await hashText(email);
|
const emailHash = await hashText(email);
|
||||||
const ipHash = await hashText(getClientIp(request));
|
const ipHash = await hashText(getClientIp(request));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,10 @@ create table if not exists public.order_groups (
|
||||||
last_sms_error text,
|
last_sms_error text,
|
||||||
next_notification_check_at timestamptz,
|
next_notification_check_at timestamptz,
|
||||||
delivery_date date,
|
delivery_date date,
|
||||||
delivery_time text
|
delivery_time text,
|
||||||
|
delivery_address text,
|
||||||
|
manual_confirmation_at timestamptz,
|
||||||
|
assigned_driver_id uuid references public.users (id)
|
||||||
);
|
);
|
||||||
|
|
||||||
create table if not exists public.delivery_invitations (
|
create table if not exists public.delivery_invitations (
|
||||||
|
|
@ -342,6 +345,9 @@ as $$
|
||||||
join public.roles r on r.id = u.role_id
|
join public.roles r on r.id = u.role_id
|
||||||
where u.id = auth.uid()
|
where u.id = auth.uid()
|
||||||
$$;
|
$$;
|
||||||
|
-- Disable row-level security for this function so it can read users
|
||||||
|
-- without triggering infinite recursion via users RLS policies.
|
||||||
|
alter function public.current_role_name() set row_security = off;
|
||||||
|
|
||||||
create or replace function public.handle_new_user()
|
create or replace function public.handle_new_user()
|
||||||
returns trigger
|
returns trigger
|
||||||
|
|
@ -615,16 +621,16 @@ begin
|
||||||
to_jsonb(v_group.order_numbers) ->> 0,
|
to_jsonb(v_group.order_numbers) ->> 0,
|
||||||
nullif(v_group.group_key, '')
|
nullif(v_group.group_key, '')
|
||||||
);
|
);
|
||||||
v_customer_name := coalesce(
|
v_customer_name := case
|
||||||
nullif(v_group.customer_name, ''),
|
when length(coalesce(nullif(v_group.customer_name, ''), nullif(v_invitation.customer_name, ''))) > 0
|
||||||
nullif(v_group.customer ->> 'name', ''),
|
then left(coalesce(nullif(v_group.customer_name, ''), nullif(v_invitation.customer_name, '')), 1) || '.'
|
||||||
nullif(v_invitation.customer_name, '')
|
else null
|
||||||
);
|
end;
|
||||||
v_customer_phone := coalesce(
|
v_customer_phone := case
|
||||||
nullif(v_group.customer_phone, ''),
|
when length(coalesce(nullif(v_group.customer_phone, ''), nullif(v_group.customer_phone_normalized, ''), nullif(v_invitation.customer_phone, ''))) >= 4
|
||||||
nullif(v_group.customer ->> 'phone', ''),
|
then '+7 *** ***-' || right(coalesce(nullif(v_group.customer_phone, ''), nullif(v_group.customer_phone_normalized, ''), nullif(v_invitation.customer_phone, '')), 2)
|
||||||
nullif(v_invitation.customer_phone, '')
|
else coalesce(nullif(v_group.customer_phone, ''), nullif(v_group.customer_phone_normalized, ''), nullif(v_invitation.customer_phone, ''))
|
||||||
);
|
end;
|
||||||
select coalesce(
|
select coalesce(
|
||||||
jsonb_agg(jsonb_build_object('name', order_number, 'quantity', '')),
|
jsonb_agg(jsonb_build_object('name', order_number, 'quantity', '')),
|
||||||
'[]'::jsonb
|
'[]'::jsonb
|
||||||
|
|
@ -964,13 +970,36 @@ using (public.current_role_name() = 'admin');
|
||||||
drop policy if exists "users self or admin" on public.users;
|
drop policy if exists "users self or admin" on public.users;
|
||||||
create policy "users self or admin" on public.users
|
create policy "users self or admin" on public.users
|
||||||
for select
|
for select
|
||||||
using (public.current_role_name() = 'admin' or id = auth.uid());
|
using (id = auth.uid());
|
||||||
|
|
||||||
|
-- Helper to check admin role without RLS recursion.
|
||||||
|
create or replace function public.is_admin()
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as 81937
|
||||||
|
select exists (
|
||||||
|
select 1 from public.users u
|
||||||
|
join public.roles r on r.id = u.role_id
|
||||||
|
where u.id = auth.uid() and r.name = 'admin'
|
||||||
|
)
|
||||||
|
81937;
|
||||||
|
alter function public.is_admin() set row_security = off;
|
||||||
|
|
||||||
drop policy if exists "users admin update" on public.users;
|
drop policy if exists "users admin update" on public.users;
|
||||||
create policy "users admin update" on public.users
|
create policy "users admin update" on public.users
|
||||||
for all
|
for all
|
||||||
using (public.current_role_name() = 'admin')
|
using (public.is_admin())
|
||||||
with check (public.current_role_name() = 'admin');
|
with check (public.is_admin());
|
||||||
|
|
||||||
|
drop policy if exists "users readable by logistics" on public.users;
|
||||||
|
create policy "users readable by logistics" on public.users
|
||||||
|
for select
|
||||||
|
using (
|
||||||
|
auth.role() in ('authenticated', 'service_role')
|
||||||
|
);
|
||||||
|
|
||||||
drop policy if exists "orders select by role" on public.orders;
|
drop policy if exists "orders select by role" on public.orders;
|
||||||
create policy "orders select by role" on public.orders
|
create policy "orders select by role" on public.orders
|
||||||
|
|
@ -1072,18 +1101,25 @@ with check (public.current_role_name() in ('manager', 'production_lead', 'logist
|
||||||
drop policy if exists "order groups select by role" on public.order_groups;
|
drop policy if exists "order groups select by role" on public.order_groups;
|
||||||
create policy "order groups select by role" on public.order_groups
|
create policy "order groups select by role" on public.order_groups
|
||||||
for select
|
for select
|
||||||
using (true);
|
using (
|
||||||
|
public.current_role_name() in ('manager', 'logistician', 'driver', 'admin')
|
||||||
|
or exists (
|
||||||
|
select 1 from public.delivery_invitations di
|
||||||
|
where di.order_group_id = order_groups.id
|
||||||
|
and di.state in ('awaiting_choice', 'opened', 'reminder_sent')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
drop policy if exists "order groups update coordination roles" on public.order_groups;
|
drop policy if exists "order groups update coordination roles" on public.order_groups;
|
||||||
create policy "order groups update coordination roles" on public.order_groups
|
create policy "order groups update coordination roles" on public.order_groups
|
||||||
for update
|
for update
|
||||||
using (public.current_role_name() in ('manager', 'logistician', 'admin'))
|
using (public.current_role_name() in ('manager', 'logistician', 'admin'))
|
||||||
with check (public.current_role_name() in ('manager', 'logistician', 'admin'));
|
with check (public.current_role_name() in ('manager', 'logistician', 'admin') or (auth.jwt()->>'role') = 'service_role');
|
||||||
|
|
||||||
drop policy if exists "order groups insert service roles" on public.order_groups;
|
drop policy if exists "order groups insert service roles" on public.order_groups;
|
||||||
create policy "order groups insert service roles" on public.order_groups
|
create policy "order groups insert service roles" on public.order_groups
|
||||||
for insert
|
for insert
|
||||||
with check (public.current_role_name() in ('manager', 'logistician', 'admin'));
|
with check (public.current_role_name() in ('manager', 'logistician', 'admin') or (auth.jwt()->>'role') = 'service_role');
|
||||||
|
|
||||||
drop policy if exists "slots by order role" on public.delivery_slots;
|
drop policy if exists "slots by order role" on public.delivery_slots;
|
||||||
create policy "slots by order role" on public.delivery_slots
|
create policy "slots by order role" on public.delivery_slots
|
||||||
|
|
@ -1177,3 +1213,155 @@ create policy "integration events admin only" on public.integration_events
|
||||||
for all
|
for all
|
||||||
using (public.current_role_name() = 'admin')
|
using (public.current_role_name() = 'admin')
|
||||||
with check (public.current_role_name() = 'admin');
|
with check (public.current_role_name() = 'admin');
|
||||||
|
|
||||||
|
-- RPC для получения списка водителей (обход RLS)
|
||||||
|
create or replace function public.get_drivers()
|
||||||
|
returns table (
|
||||||
|
id uuid,
|
||||||
|
email text,
|
||||||
|
name text
|
||||||
|
)
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
return query
|
||||||
|
select u.id, u.email, u.name
|
||||||
|
from public.users u
|
||||||
|
join public.roles r on r.id = u.role_id
|
||||||
|
where r.name = 'driver'
|
||||||
|
order by u.name;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke execute on function public.get_drivers() from anon;
|
||||||
|
grant execute on function public.get_drivers() to authenticated;
|
||||||
|
|
||||||
|
|
||||||
|
-- Audit log for admin actions
|
||||||
|
create table if not exists public.audit_log (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
actor_id uuid references auth.users (id) on delete set null,
|
||||||
|
action text not null,
|
||||||
|
target_type text,
|
||||||
|
target_id text,
|
||||||
|
metadata jsonb not null default '{}'::jsonb,
|
||||||
|
created_at timestamptz not null default timezone('utc', now())
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.audit_log enable row level security;
|
||||||
|
|
||||||
|
create policy "audit admin only" on public.audit_log
|
||||||
|
for all
|
||||||
|
using (public.current_role_name() = 'admin')
|
||||||
|
with check (public.current_role_name() = 'admin');
|
||||||
|
|
||||||
|
create index if not exists idx_audit_log_actor_id on public.audit_log (actor_id);
|
||||||
|
create index if not exists idx_audit_log_action on public.audit_log (action);
|
||||||
|
create index if not exists idx_audit_log_target on public.audit_log (target_type, target_id);
|
||||||
|
create index if not exists idx_audit_log_created_at on public.audit_log (created_at desc);
|
||||||
|
|
||||||
|
-- Trigger: log role changes
|
||||||
|
create or replace function public.log_role_change()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
insert into public.audit_log (actor_id, action, target_type, target_id, metadata)
|
||||||
|
values (
|
||||||
|
auth.uid(),
|
||||||
|
tg_op = 'INSERT' then 'role_created'::text else 'role_updated'::text end,
|
||||||
|
'role',
|
||||||
|
new.id::text,
|
||||||
|
jsonb_build_object(
|
||||||
|
'name', new.name,
|
||||||
|
'permissions', new.permissions,
|
||||||
|
'old_name', case when tg_op = 'UPDATE' then old.name else null end
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists on_role_change on public.roles;
|
||||||
|
create trigger on_role_change
|
||||||
|
after insert or update on public.roles
|
||||||
|
for each row
|
||||||
|
execute function public.log_role_change();
|
||||||
|
|
||||||
|
-- Trigger: log user changes
|
||||||
|
create or replace function public.log_user_change()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
insert into public.audit_log (actor_id, action, target_type, target_id, metadata)
|
||||||
|
values (
|
||||||
|
auth.uid(),
|
||||||
|
case tg_op
|
||||||
|
when 'INSERT' then 'user_created'
|
||||||
|
when 'UPDATE' then 'user_updated'
|
||||||
|
when 'DELETE' then 'user_deleted'
|
||||||
|
end,
|
||||||
|
'user',
|
||||||
|
coalesce(new.id, old.id)::text,
|
||||||
|
jsonb_build_object(
|
||||||
|
'email', coalesce(new.email, old.email),
|
||||||
|
'name', coalesce(new.name, old.name),
|
||||||
|
'role_id', coalesce(new.role_id, old.role_id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return coalesce(new, old);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists on_user_change on public.users;
|
||||||
|
create trigger on_user_change
|
||||||
|
after insert or update or delete on public.users
|
||||||
|
for each row
|
||||||
|
execute function public.log_user_change();
|
||||||
|
|
||||||
|
-- RPC for driver to update delivery status
|
||||||
|
-- Validates that the requesting user is the assigned driver
|
||||||
|
create or replace function public.update_delivery_status(
|
||||||
|
p_order_group_id uuid,
|
||||||
|
p_status text
|
||||||
|
)
|
||||||
|
returns boolean
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_assigned_driver_id uuid;
|
||||||
|
v_current_status text;
|
||||||
|
begin
|
||||||
|
select assigned_driver_id, delivery_status
|
||||||
|
into v_assigned_driver_id, v_current_status
|
||||||
|
from public.order_groups
|
||||||
|
where id = p_order_group_id;
|
||||||
|
|
||||||
|
if v_assigned_driver_id is null then
|
||||||
|
raise exception 'Группа не назначена водителю';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_assigned_driver_id != auth.uid() then
|
||||||
|
raise exception 'Вы не назначены на эту доставку';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.order_groups
|
||||||
|
set delivery_status = p_status,
|
||||||
|
updated_at = timezone('utc', now())
|
||||||
|
where id = p_order_group_id;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke execute on function public.update_delivery_status(uuid, text) from anon;
|
||||||
|
grant execute on function public.update_delivery_status(uuid, text) to authenticated;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue