Extract the operational Model Delivery PWA
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
# Development / preview proxy only. Never put secrets in VITE_ variables.
|
||||||
|
API_PORT=3001
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.png binary
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!infra/synology/.env.production.example
|
||||||
|
coverage/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
work/
|
||||||
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
.image/
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Model Delivery engineering rules
|
||||||
|
|
||||||
|
- Current authorized scope also includes planned delivery dates, a local dashboard/calendar with common-action shortcuts and operational stored-price summaries, delivery editing/cancellation, explicit conflict resolution, editable/archiveable customer profiles with contact details, structured addresses, delivery windows/comments/related records, Synology OIDC sign-in with a configurable group allowlist, a personal greeting, and one local recovery account with guarded SSO settings at /local-admin. Keep dispatch, courier, approval, accounting reports and WhatsApp work out of this slice.
|
||||||
|
- Hebrew UI and RTL; English source, comments and technical documentation.
|
||||||
|
- Use pnpm and strict TypeScript. This repository has a flat src/ layout. Repository ownership and shared package release rules are in docs/repositories.md.
|
||||||
|
- All operational writes commit to IndexedDB with their outbox entry atomically before any network request. Authentication and SSO administration require an online server. Never perform network I/O inside an IndexedDB transaction.
|
||||||
|
- Never expose MariaDB to browsers. Require server sessions for sync; the authentication foundation remains local/loopback until deployment hardening is completed. Keep each user's device cache separate and pause sync on 401 without losing queued writes.
|
||||||
|
- Normal authentication uses OIDC Authorization Code + PKCE, state, nonce and signature/issuer/audience checks through openid-client. Provider tokens and client secrets stay encrypted on the API server. Never put them in browser storage or logs. offline_access is optional and does not enable offline API access.
|
||||||
|
- OIDC group access uses the verified configured claim, exact normalized case-insensitive allowlist matching and a stored last-login snapshot. Reject unmatched users before provisioning or session creation. Keep restriction disabled until an allowlist is configured, keep the local recovery account exempt, and require a fresh SSO login to learn changed provider membership.
|
||||||
|
- /local-admin is unlisted, authenticated and restricted to loopback requests. Never replace these controls with a secret URL or an unauthenticated bypass. Exactly one password-based account may be bootstrapped using LOCAL_ADMIN_SETUP_TOKEN; no shipped/default credentials. Deny the admin routes at a future public reverse proxy.
|
||||||
|
- Optional AD lookup enriches display names only after verified OIDC; never use it to bypass sign-in or infer roles. Require configured issuer/domain matching, exact escaped user filters, verified LDAPS, bounded operations and encrypted read credentials. Keep directory settings/tests behind the same local-admin controls. Preserve manual names and use cached names during directory outages.
|
||||||
|
- Apply DB changes only through checked-in migrations. Do not modify an already applied migration. MariaDB DDL implicitly commits; keep migrations restart-safe.
|
||||||
|
- Idempotency receipts, entity writes, business-number allocation and change log entries share one server transaction. Hold the sync_state lock until commit so the delta feed cannot miss late commits.
|
||||||
|
- UUIDs are entity identities. Delivery numbers are server-controlled, monotonically allocated and never reused after commit, even after future cancellation.
|
||||||
|
- No weight field. Shipment-unit multipliers live in reference data, not hardcoded business logic. Store immutable fixed-point shipment-unit and pricing snapshots on deliveries.
|
||||||
|
- Require each delivery address to belong to the selected customer, validated locally, by the API and by a composite foreign key.
|
||||||
|
- No silent conflict resolution. Preserve the local draft and server conflict details. Use version checks for future edits. Synchronized deletions must use tombstones and change-log entries.
|
||||||
|
- Pull cursors are opaque decimal strings. Never advance them from push receipts. Keep outbox entries until a valid server acknowledgment is durably applied.
|
||||||
|
- Sync on launch, reconnect, foreground, manual request, and periodically while visible. Do not depend on background sync support.
|
||||||
|
- Responsive layouts support phones and tablets in both orientations. Use logical CSS properties, 44px touch targets, safe areas and 100dvh.
|
||||||
|
- Never commit .env, database files or secrets. Avoid logging request bodies and customer data.
|
||||||
|
- Before delivery: pnpm typecheck, pnpm test, pnpm build; for sync changes also run the isolated MariaDB integration and Playwright offline/reconnect tests (README has setup).
|
||||||
|
- Do not reset, drop, or truncate working databases to fix tests. Use model_delivery_test.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Model Delivery web
|
||||||
|
|
||||||
|
Hebrew RTL React/Vite PWA with Dexie offline data and a durable outbox. All existing screens and branding are retained.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
corepack enable
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
# Copy .env.example to .env; start delivery-app-api separately.
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:5173. API calls remain same-origin /api; Vite proxies locally to port 3001. Production must proxy /api to the API and block public /local-admin and /api/local-admin. Never serve this frontend on an unrelated origin without designing authentication accordingly.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm test
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Output: dist/. For browser regression tests, clone delivery-app-api alongside this repo, install/build it, configure its local .env and run its pnpm db:test:prepare (isolated model_delivery_test only). Then run pnpm exec playwright install chromium and pnpm test:e2e here. MODEL_API_DIR can override the API checkout path. Do not run two suites against the same test database concurrently.
|
||||||
|
|
||||||
|
The API repo can bundle this dist into the existing single-container NAS deployment; see its README. [Repository map](docs/repositories.md).
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Model Delivery repositories
|
||||||
|
|
||||||
|
| Repository | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| delivery-app-web | React/Vite/Dexie PWA, browser tests |
|
||||||
|
| delivery-app-api | Fastify, Synology SSO, MariaDB migrations, deployment |
|
||||||
|
| delivery-app-mobile | React Native/Expo for iOS and Android |
|
||||||
|
| delivery-app-shared | Versioned TypeScript/Zod contracts and pricing rules |
|
||||||
|
|
||||||
|
Clone from https://git.ruff.co.il/model-dlab/ using your own Git credential helper. Never put credentials in URLs or package manifests. Sibling checkouts are convenient, but ordinary installs/builds need no sibling source. Browser E2E and the combined NAS image explicitly use the web and API checkouts.
|
||||||
|
|
||||||
|
The shared dependency is pinned to an immutable Git commit, including its compiled dist files. There is no public npm publication or requirement for a private npm registry. Build, test and commit shared source AND dist together, tag releases, then explicitly update the Git SHA and pnpm lockfile in each consumer. TypeScript code can be reused on mobile; DOM, IndexedDB and Node-specific code cannot.
|
||||||
|
|
||||||
|
All clients use one API and the same operational data. SQLite on mobile will implement the existing sync protocol. Do not change deployed database identities, credentials, encryption keys, or OIDC configuration during this source split. The original model-app checkout remains available.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="he" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#2D3845" />
|
||||||
|
<meta name="description" content="ניהול משלוחי מעבדת שיניים — גם ללא חיבור לרשת" />
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
|
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
|
||||||
|
<link rel="icon" href="/favicon-48.png" type="image/png" sizes="48x48" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
|
||||||
|
<title>מודל משלוחים</title>
|
||||||
|
</head>
|
||||||
|
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "@model/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview --host 127.0.0.1",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:e2e": "pnpm build && playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/heebo": "5.3.0",
|
||||||
|
"@model/shared": "git+https://git.ruff.co.il/model-dlab/delivery-app-shared.git#9133b921a713b35c46a138f9e83d1ccfbf56417e",
|
||||||
|
"dexie": "4.4.5",
|
||||||
|
"dexie-react-hooks": "4.4.0",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.2.18",
|
||||||
|
"@types/react-dom": "19.2.7",
|
||||||
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
|
"vite": "8.2.2",
|
||||||
|
"vite-plugin-pwa": "1.3.0",
|
||||||
|
"workbox-build": "^7.4.1",
|
||||||
|
"workbox-window": "^7.4.1",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"@types/node": "^22.19.0",
|
||||||
|
"vitest": "^4.1.0",
|
||||||
|
"fake-indexeddb": "^6.2.4",
|
||||||
|
"@playwright/test": "1.62.1",
|
||||||
|
"jose": "^6.2.11"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.10.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12 <23",
|
||||||
|
"pnpm": ">=10 <11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
const apiDirectory = resolve(process.env.MODEL_API_DIR ?? '../delivery-app-api');
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
timeout: 45000,
|
||||||
|
expect: { timeout: 15000 },
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
reporter: 'list',
|
||||||
|
use: { baseURL: 'http://localhost:4174', trace: 'retain-on-failure', screenshot: 'only-on-failure', serviceWorkers: 'allow' },
|
||||||
|
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||||
|
webServer: [
|
||||||
|
{ command: `pnpm --dir "${apiDirectory}" start`, url: 'http://127.0.0.1:3002/ready', env: { API_PORT: '3002', DB_NAME: 'model_delivery_test', APP_ORIGINS: 'http://localhost:4174', LOCAL_ADMIN_SETUP_TOKEN: 'isolated-test-bootstrap', OIDC_ALLOW_HTTP: 'true' }, reuseExistingServer: false, timeout: 30000 },
|
||||||
|
{ command: 'pnpm preview --port 4174', url: 'http://localhost:4174', env: { API_PORT: '3002' }, reuseExistingServer: false, timeout: 30000 },
|
||||||
|
{ command: 'node tests/helpers/mock-oidc.mjs', url: 'http://localhost:4005/.well-known/openid-configuration', reuseExistingServer: false, timeout: 30000 },
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
packages: []
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,211 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react';
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
|
import { useRegisterSW } from 'virtual:pwa-register/react';
|
||||||
|
import { totalPrice, userSchema, type DeliverySlot, type User } from '@model/shared';
|
||||||
|
import { DeliveryActions } from './DeliveryActions';
|
||||||
|
import { CustomersView } from './CustomersView';
|
||||||
|
import { DashboardView } from './DashboardView';
|
||||||
|
import { CostsView } from './CostsView';
|
||||||
|
import { deliveryDateKey, toDateKey } from './analytics';
|
||||||
|
import { db, saveLocal, type SyncState } from './db';
|
||||||
|
import { ConnectionError, retryErrors, synchronize } from './sync';
|
||||||
|
import { BrandLogo } from './BrandLogo';
|
||||||
|
import { deliverySlotOptions, deliveryTimingInput, deliveryTimingText, windowText } from './deliveryTiming';
|
||||||
|
|
||||||
|
const currency = new Intl.NumberFormat('he-IL', { style: 'currency', currency: 'ILS' });
|
||||||
|
const dateFormat = new Intl.DateTimeFormat('he-IL', { dateStyle: 'short', timeStyle: 'short' });
|
||||||
|
const statusLabels: Record<SyncState, string> = { pending: 'ממתין לסנכרון', synced: 'מסונכרן', error: 'שגיאת סנכרון' };
|
||||||
|
function Badge({ state }: { state: SyncState }) { return <span className={`badge ${state}`}><span aria-hidden="true">●</span> {statusLabels[state]}</span>; }
|
||||||
|
type View = 'dashboard' | 'deliveries' | 'customers' | 'costs';
|
||||||
|
const viewCopy: Record<View, { path: string; title: string; description: string }> = {
|
||||||
|
dashboard: { path: 'לוח בקרה', title: 'תמונת המצב שלך', description: 'המשלוחים של היום והשבוע, מסודרים יום אחר יום.' },
|
||||||
|
deliveries: { path: 'משלוחים', title: 'המשלוחים שלך', description: 'אספקות, איסופים וכל מה שבדרך.' },
|
||||||
|
customers: { path: 'לקוחות', title: 'הלקוחות שלך', description: 'אנשי קשר, כתובות, חלונות מסירה וכל הרשומות במקום אחד.' },
|
||||||
|
costs: { path: 'עלויות', title: 'עלויות משלוחים', description: 'סיכום המחירים השמורים לפי יום, שבוע, חודש ולקוח.' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function App({ user, onLogout, onProfileChange }: { user: User; onLogout: () => Promise<void>; onProfileChange: (user: User) => void }) {
|
||||||
|
const customers = useLiveQuery(() => db.customers.toArray(), []) ?? [];
|
||||||
|
const addresses = useLiveQuery(() => db.addresses.toArray(), []) ?? [];
|
||||||
|
const types = useLiveQuery(() => db.deliveryTypes.toArray(), []) ?? [];
|
||||||
|
const deliveries = useLiveQuery(() => db.deliveries.orderBy('createdAt').reverse().toArray(), []) ?? [];
|
||||||
|
const outbox = useLiveQuery(() => db.outbox.toArray(), []) ?? [];
|
||||||
|
const lastSynced = useLiveQuery(() => db.meta.get('lastSyncedAt'), []);
|
||||||
|
const [view, setView] = useState<View>('dashboard');
|
||||||
|
const [online, setOnline] = useState(navigator.onLine);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [syncError, setSyncError] = useState('');
|
||||||
|
const [formError, setFormError] = useState('');
|
||||||
|
const [notice, setNotice] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [customerId, setCustomerId] = useState('');
|
||||||
|
const [addressId, setAddressId] = useState('');
|
||||||
|
const [typeId, setTypeId] = useState('');
|
||||||
|
const [unitPrice, setUnitPrice] = useState('0');
|
||||||
|
const [scheduledDate, setScheduledDate] = useState(() => toDateKey(new Date()));
|
||||||
|
const [deliverySlot, setDeliverySlot] = useState<DeliverySlot>('morning');
|
||||||
|
const [specificTime, setSpecificTime] = useState('09:00');
|
||||||
|
const [deliveryDateFilter, setDeliveryDateFilter] = useState<string | null>(null);
|
||||||
|
const [customerCreateRequest, setCustomerCreateRequest] = useState(0);
|
||||||
|
const [selectedId, setSelectedId] = useState<string>();
|
||||||
|
const [accountOpen, setAccountOpen] = useState(false);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [profileBusy, setProfileBusy] = useState(false);
|
||||||
|
const [profileError, setProfileError] = useState('');
|
||||||
|
const retryTimer = useRef<number | undefined>(undefined);
|
||||||
|
const retryDelay = useRef(1000);
|
||||||
|
const mounted = useRef(true);
|
||||||
|
const initialSyncStarted = useRef(false);
|
||||||
|
const { needRefresh: [needRefresh], offlineReady: [offlineReady], updateServiceWorker } = useRegisterSW();
|
||||||
|
|
||||||
|
async function saveProfile(event?: FormEvent<HTMLFormElement>) {
|
||||||
|
event?.preventDefault(); setProfileBusy(true); setProfileError('');
|
||||||
|
const displayName = event ? new FormData(event.currentTarget).get('displayName') : null;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/profile', { method: 'POST', headers: { 'content-type': 'application/json', 'x-model-request': '1', 'x-model-user': user.id }, body: JSON.stringify({ displayName }), signal: AbortSignal.timeout(10000) });
|
||||||
|
if (response.status === 401) window.dispatchEvent(new Event('model-session-expired'));
|
||||||
|
if (!response.ok) throw new Error('לא ניתן לשמור את השם כרגע. בדקו את החיבור וההתחברות.');
|
||||||
|
const updated = userSchema.parse((await response.json()).user);
|
||||||
|
if (updated.id !== user.id) throw new Error('החשבון השתנה. יש לרענן את העמוד.');
|
||||||
|
onProfileChange(updated); setAccountOpen(false); setNotice('השם לתצוגה נשמר');
|
||||||
|
} catch (failure) { setProfileError(failure instanceof TypeError ? 'נדרש חיבור לשרת כדי לשמור את השם.' : failure instanceof Error ? failure.message : 'לא ניתן לשמור את השם.'); }
|
||||||
|
finally { setProfileBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncNow = useCallback(async () => {
|
||||||
|
window.clearTimeout(retryTimer.current);
|
||||||
|
setSyncing(true);
|
||||||
|
try { await synchronize(); setSyncError(''); retryDelay.current = 1000; }
|
||||||
|
catch (error) {
|
||||||
|
setSyncError(error instanceof Error && !error.message.startsWith('[') ? error.message : 'הסנכרון לא הושלם. הנתונים שמורים במכשיר.');
|
||||||
|
// An online signal can precede usable connectivity. Retry with backoff while open.
|
||||||
|
if (error instanceof ConnectionError && navigator.onLine && mounted.current) {
|
||||||
|
window.clearTimeout(retryTimer.current);
|
||||||
|
retryTimer.current = window.setTimeout(() => { void syncNow(); }, retryDelay.current);
|
||||||
|
retryDelay.current = Math.min(retryDelay.current * 2, 30000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { setSyncing(false); }
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
const reconnect = () => { setOnline(true); void syncNow(); };
|
||||||
|
const disconnect = () => setOnline(false);
|
||||||
|
const foreground = () => { if (document.visibilityState === 'visible') void syncNow(); };
|
||||||
|
window.addEventListener('online', reconnect);
|
||||||
|
window.addEventListener('offline', disconnect);
|
||||||
|
document.addEventListener('visibilitychange', foreground);
|
||||||
|
const interval = window.setInterval(() => { if (document.visibilityState === 'visible' && navigator.onLine) void syncNow(); }, 30000);
|
||||||
|
if (navigator.onLine && !initialSyncStarted.current) { initialSyncStarted.current = true; void syncNow(); }
|
||||||
|
return () => { mounted.current = false; window.clearTimeout(retryTimer.current); window.removeEventListener('online', reconnect); window.removeEventListener('offline', disconnect); document.removeEventListener('visibilitychange', foreground); clearInterval(interval); };
|
||||||
|
}, [syncNow]);
|
||||||
|
|
||||||
|
const activeCustomers = customers.filter(c => !c.deletedAt);
|
||||||
|
const availableAddresses = addresses.filter(a => a.customerId === customerId && !a.deletedAt);
|
||||||
|
const availableTypes = types.filter(t => !t.deletedAt);
|
||||||
|
const selectedType = availableTypes.find(t => t.id === typeId) ?? availableTypes[0];
|
||||||
|
const selectedCustomer = activeCustomers.find(customer => customer.id === customerId);
|
||||||
|
const allVisibleDeliveries = deliveries.filter(d => !d.deletedAt);
|
||||||
|
const visibleDeliveries = allVisibleDeliveries.filter(d => !deliveryDateFilter || deliveryDateKey(d) === deliveryDateFilter);
|
||||||
|
const selected = visibleDeliveries.find(d => d.id === selectedId) ?? visibleDeliveries[0];
|
||||||
|
const errors = outbox.filter(e => e.state === 'error');
|
||||||
|
const pending = outbox.length - errors.length;
|
||||||
|
|
||||||
|
async function saveEntity(kind: 'customer.create' | 'address.create', data: unknown) {
|
||||||
|
const id = await saveLocal(kind, data);
|
||||||
|
void navigator.storage?.persist?.().catch(() => false);
|
||||||
|
if (navigator.onLine) void syncNow();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(event: FormEvent<HTMLFormElement>, kind: 'customer.create' | 'address.create' | 'delivery.create') {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const fields = new FormData(form);
|
||||||
|
setSaving(true); setFormError(''); setNotice('');
|
||||||
|
try {
|
||||||
|
let data: unknown;
|
||||||
|
if (kind === 'customer.create') data = { name: fields.get('name') };
|
||||||
|
else if (kind === 'address.create') data = { customerId: fields.get('customerId'), label: fields.get('label'), address: fields.get('address') };
|
||||||
|
else {
|
||||||
|
if (!selectedType) throw new Error('יש להתחבר פעם אחת כדי לטעון את סוגי המשלוח');
|
||||||
|
data = { customerId, addressId, scheduledDate, ...deliveryTimingInput(selectedCustomer, deliverySlot, specificTime), deliveryTypeId: selectedType.id, deliveryTypeVersion: selectedType.version, shipmentUnitHundredths: selectedType.shipmentUnitHundredths, unitPriceAgorot: Math.round(Number(unitPrice) * 100), notes: fields.get('notes') ?? '' };
|
||||||
|
}
|
||||||
|
const id = await saveLocal(kind, data);
|
||||||
|
setNotice('נשמר במכשיר בהצלחה');
|
||||||
|
if (kind === 'delivery.create') {
|
||||||
|
setSelectedId(id); setUnitPrice('0');
|
||||||
|
(form.elements.namedItem('notes') as HTMLTextAreaElement).value = '';
|
||||||
|
} else form.reset();
|
||||||
|
// Request persistence after a user gesture; browsers can still decline it.
|
||||||
|
void navigator.storage?.persist?.().catch(() => false);
|
||||||
|
if (navigator.onLine) void syncNow();
|
||||||
|
} catch (error) {
|
||||||
|
setFormError(error instanceof Error && !error.message.startsWith('[') ? error.message : 'לא ניתן לשמור. יש לבדוק את השדות ואת האחסון במכשיר.');
|
||||||
|
} finally { setSaving(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(next: View) { if (next === 'deliveries') setDeliveryDateFilter(null); setView(next); setMenuOpen(false); }
|
||||||
|
function openDeliveries(date: string) { setScheduledDate(date); setDeliveryDateFilter(date); setView('deliveries'); setMenuOpen(false); }
|
||||||
|
function openNewCustomer() { setCustomerCreateRequest(value => value + 1); setView('customers'); setMenuOpen(false); }
|
||||||
|
return <div className="app-shell">
|
||||||
|
{menuOpen && <button className="sidebar-backdrop" aria-label="סגירת תפריט" onClick={() => setMenuOpen(false)} />}
|
||||||
|
<aside className={`sidebar ${menuOpen ? 'open' : ''}`}>
|
||||||
|
<a className="brand" href="#" aria-label="מודל דנטל לאב — לוח הבקרה" onClick={e => { e.preventDefault(); navigate('dashboard'); }}><BrandLogo className="sidebar-logo" alt="" /></a>
|
||||||
|
<nav aria-label="ניווט ראשי">
|
||||||
|
<button aria-current={view === 'dashboard' ? 'page' : undefined} onClick={() => navigate('dashboard')}><span className="nav-icon" aria-hidden="true">◫</span> לוח בקרה</button>
|
||||||
|
<button aria-current={view === 'deliveries' ? 'page' : undefined} onClick={() => navigate('deliveries')}><span className="nav-icon" aria-hidden="true">▤</span> משלוחים</button>
|
||||||
|
<button aria-current={view === 'customers' ? 'page' : undefined} onClick={() => navigate('customers')}><span className="nav-icon" aria-hidden="true">◎</span> לקוחות</button>
|
||||||
|
<button aria-current={view === 'costs' ? 'page' : undefined} onClick={() => navigate('costs')}><span className="nav-icon" aria-hidden="true">₪</span> עלויות</button>
|
||||||
|
</nav>
|
||||||
|
<div className="sidebar-note"><span className="eyebrow">סביבת עבודה מקומית</span><p>כל שמירה מתחילה במכשיר שלך.</p><span>הנתונים מסתנכרנים כשהחיבור זמין.</span></div>
|
||||||
|
</aside>
|
||||||
|
<main>
|
||||||
|
<header className="topbar"><div className="topbar-path"><button className="hamburger" aria-label="פתיחת תפריט" aria-expanded={menuOpen} onClick={() => setMenuOpen(true)}><span /><span /><span /></button><span>המעבדה שלי <span className="separator">/</span> {viewCopy[view].path}</span></div><div className="topbar-account"><span className={`connection ${online && !syncError ? 'connected' : ''}`}><span aria-hidden="true">●</span> {!online ? 'ללא חיבור לרשת' : syncError ? 'השרת אינו זמין' : 'מחובר לרשת'}</span><button className="account-button" aria-expanded={accountOpen} aria-label="החשבון שלי" onClick={() => setAccountOpen(!accountOpen)}>{user.displayName.slice(0, 1)}</button>{accountOpen && <div className="account-menu"><strong>{user.displayName}</strong><span dir="ltr">{user.username}</span><span>{user.role === 'admin' ? 'מנהל מערכת' : 'משתמש'}</span>{user.groups.length > 0 && <span className="account-groups">קבוצות: {user.groups.join(', ')}</span>}<p className="helper">שינויים ממתינים יישמרו לחשבון שלך עד הכניסה הבאה.</p><form className="profile-name-form" onSubmit={event => void saveProfile(event)}><label>שם לתצוגה<input key={user.displayName} name="displayName" defaultValue={user.displayName} required maxLength={80} autoComplete="name" /></label><p className="helper">השם שיופיע בברכה האישית. שמירת השם דורשת חיבור.</p>{profileError && <p className="alert error" role="alert">{profileError}</p>}<button className="secondary" disabled={profileBusy || !online}>{profileBusy ? 'שומר…' : 'שמירת השם'}</button>{user.authProvider === 'oidc' && <button className="secondary" type="button" disabled={profileBusy || !online} onClick={() => void saveProfile()}>שימוש בשם הארגוני</button>}</form><button className="secondary" disabled={syncing || saving || profileBusy || !online} onClick={() => { void onLogout().catch(() => setFormError('לא ניתן לצאת כרגע. יש לנסות שוב כשהשרת זמין.')); }}>יציאה מהחשבון</button></div>}</div></header>
|
||||||
|
<div className="content">
|
||||||
|
<p className="greeting">שלום, {user.displayName}</p>
|
||||||
|
<div className="page-heading"><div><p className="eyebrow">מודל · מעבדת שיניים</p><h1>{viewCopy[view].title}</h1><p>{viewCopy[view].description}</p></div><button className="secondary sync-button" disabled={syncing || !online} onClick={() => void syncNow()}>{syncing ? 'מסנכרן…' : '↻ סנכרן עכשיו'}</button></div>
|
||||||
|
<div className="sync-strip" role="status"><span>{errors.length > 0 ? `${errors.length} פעולות דורשות בדיקה` : pending > 0 ? `${pending} פעולות ממתינות לסנכרון` : lastSynced ? 'כל השינויים סונכרנו' : 'טוען נתונים ראשונים'}</span><span>{lastSynced ? `סנכרון אחרון: ${dateFormat.format(new Date(lastSynced.value))}` : 'ממתין לסנכרון ראשון'}</span></div>
|
||||||
|
{syncError && <p className="alert">{syncError}</p>}
|
||||||
|
{errors.length > 0 && <div className="alert error" role="alert"><strong>{errors.length} פעולות דורשות בדיקה</strong>{errors.map(e => <p key={e.operationId}>{e.error}</p>)}<button className="secondary" disabled={syncing} onClick={() => { void retryErrors().then(syncNow).catch(() => setFormError('לא ניתן להכין ניסיון חוזר')); }}>נסה שוב עם הנתונים השמורים</button></div>}
|
||||||
|
{needRefresh && <div className="alert">גרסה חדשה זמינה. <button className="secondary" disabled={saving || syncing} onClick={() => void updateServiceWorker(true)}>רענון לגרסה החדשה</button></div>}
|
||||||
|
{notice && <p className="notice" role="status">{notice}</p>}
|
||||||
|
{formError && <p className="alert error" role="alert">{formError}</p>}
|
||||||
|
|
||||||
|
{view === 'dashboard' ? <DashboardView deliveries={deliveries} customers={customers} addresses={addresses} onOpenDeliveries={openDeliveries} onNewDelivery={() => openDeliveries(toDateKey(new Date()))} onNewCustomer={openNewCustomer} onOpenCosts={() => navigate('costs')} /> : view === 'deliveries' ? <>
|
||||||
|
{deliveryDateFilter && <div className="date-filter"><span>מציג משלוחים לתאריך <strong>{new Intl.DateTimeFormat('he-IL', { dateStyle: 'long' }).format(new Date(`${deliveryDateFilter}T12:00:00`))}</strong></span><button className="secondary" onClick={() => setDeliveryDateFilter(null)}>הצגת כל המשלוחים</button></div>}
|
||||||
|
<section className="metrics" aria-label="סיכום משלוחים"><div><span>כל המשלוחים</span><strong>{visibleDeliveries.length}</strong></div><div><span>ממתינים לסנכרון</span><strong>{visibleDeliveries.filter(d => d.syncState === 'pending').length}</strong></div><div><span>סונכרנו בהצלחה</span><strong>{visibleDeliveries.filter(d => d.syncState === 'synced').length}</strong></div></section>
|
||||||
|
<div className="workspace">
|
||||||
|
<section className="panel create-panel"><div className="panel-heading"><h2>משלוח חדש</h2><span className="small">נשמר גם ללא חיבור</span></div>
|
||||||
|
<form onSubmit={e => void submit(e, 'delivery.create')}>
|
||||||
|
<label>לקוח<select name="customerId" required value={customerId} onChange={e => { setCustomerId(e.target.value); setAddressId(''); }}><option value="">בחירת לקוח</option>{activeCustomers.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}</select></label>
|
||||||
|
<label>כתובת למשלוח<select name="addressId" required value={addressId} onChange={e => setAddressId(e.target.value)} disabled={!customerId}><option value="">בחירת כתובת משויכת</option>{availableAddresses.map(a => <option key={a.id} value={a.id}>{a.label} · {a.address}</option>)}</select></label>
|
||||||
|
{customerId && !availableAddresses.length && <p className="helper">יש להוסיף כתובת ללקוח במסך לקוחות וכתובות.</p>}
|
||||||
|
<label>תאריך מתוכנן<input name="scheduledDate" type="date" required value={scheduledDate} onChange={event => setScheduledDate(event.target.value)} /></label>
|
||||||
|
<fieldset className="delivery-slot-picker">
|
||||||
|
<legend>מועד מסירה</legend>
|
||||||
|
<div className="delivery-slot-options">{deliverySlotOptions.map(option => {
|
||||||
|
const window = selectedCustomer && option.value !== 'custom' ? selectedCustomer.deliveryWindows[option.value] : null;
|
||||||
|
return <label key={option.value} className={deliverySlot === option.value ? 'selected' : ''}><input type="radio" name="deliverySlot" value={option.value} checked={deliverySlot === option.value} onChange={() => setDeliverySlot(option.value)} /><span><strong>{option.label}</strong><small>{option.value === 'custom' ? 'בחירת שעה מדויקת' : windowText(window)}</small></span></label>;
|
||||||
|
})}</div>
|
||||||
|
{deliverySlot === 'custom' && <label className="specific-time">שעה מדויקת<input aria-label="שעה מדויקת" type="time" required value={specificTime} onChange={event => setSpecificTime(event.target.value)} /></label>}
|
||||||
|
{!selectedCustomer && deliverySlot !== 'custom' && <p className="helper">לאחר בחירת לקוח יוצגו שעות החלון שהוגדרו עבורו.</p>}
|
||||||
|
</fieldset>
|
||||||
|
<div className="field-pair"><label>סוג משלוח<select aria-label="סוג משלוח" value={selectedType?.id ?? ''} onChange={e => setTypeId(e.target.value)} required>{!availableTypes.length && <option value="">ממתין לחיבור ראשון</option>}{availableTypes.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}</select></label><label>מחיר ליחידה (₪)<input name="unitPrice" type="number" min="0" max="1000000" step="0.01" required value={unitPrice} onChange={e => setUnitPrice(e.target.value)} /></label></div>
|
||||||
|
<label>הערות למשלוח <span className="optional">לא חובה</span><textarea name="notes" maxLength={500} rows={2} placeholder="פרטים שיעזרו בביצוע המשלוח" /></label>
|
||||||
|
<div className="total"><span>{(selectedType?.shipmentUnitHundredths ?? 0) / 100} יחידות משלוח</span><strong>{currency.format(totalPrice(Math.round((Number(unitPrice) || 0) * 100), selectedType?.shipmentUnitHundredths ?? 0) / 100)}</strong></div>
|
||||||
|
<button className="primary" disabled={saving || !selectedType || !addressId}>{saving ? 'שומר…' : '+ שמירת משלוח'}</button>
|
||||||
|
<p className="helper">מספר המשלוח יוקצה לאחר הסנכרון לשרת.</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section className="panel list-panel"><div className="panel-heading"><h2>משלוחים אחרונים</h2><span className="count">{visibleDeliveries.length}</span></div>
|
||||||
|
{!visibleDeliveries.length ? <div className="empty"><span className="empty-mark" aria-hidden="true">▤</span><h3>{deliveryDateFilter ? 'אין משלוחים בתאריך הזה' : 'כאן יופיע המשלוח הראשון'}</h3><p>בחרו לקוח וכתובת, והוסיפו אספקה או איסוף.</p></div> : <div className="delivery-list">{visibleDeliveries.map(d => <button className={`delivery-row ${selected?.id === d.id ? 'selected' : ''}`} key={d.id} data-testid={`delivery-${d.id}`} onClick={() => setSelectedId(d.id)} aria-pressed={selected?.id === d.id}><span className="row-top"><strong>{d.deliveryNumber ? `#${d.deliveryNumber}` : 'ממתין למספר'}</strong><Badge state={d.syncState} /></span><span className="customer-name">{d.status === 'cancelled' && <span className="cancelled-badge">מבוטל · </span>}{customers.find(c => c.id === d.customerId)?.name ?? 'לקוח'}</span><span className="delivery-timing-inline">{deliveryTimingText(d)}</span><span className="row-bottom"><span>{types.find(t => t.id === d.deliveryTypeId)?.name} · {addresses.find(a => a.id === d.addressId)?.label}</span><span>{new Intl.DateTimeFormat('he-IL', { day: 'numeric', month: 'short' }).format(new Date(`${deliveryDateKey(d)}T12:00:00`))} · {currency.format(d.totalPriceAgorot / 100)}</span></span></button>)}</div>}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{selected && <section className="panel detail-panel" aria-label="פרטי משלוח"><div className="panel-heading"><h2>פרטי משלוח {selected.deliveryNumber ? `#${selected.deliveryNumber}` : 'חדש'}</h2><Badge state={selected.syncState} /></div><div className="detail-grid"><div><span>לקוח וכתובת</span><strong>{customers.find(c => c.id === selected.customerId)?.name}</strong><p>{addresses.find(a => a.id === selected.addressId)?.address}</p></div><div><span>מחיר שנשמר למשלוח</span><strong>{currency.format(selected.totalPriceAgorot / 100)}</strong><p>{selected.shipmentUnitHundredths / 100} יחידות × {currency.format(selected.unitPriceAgorot / 100)}</p></div><div><span>תאריך ומועד מסירה</span><strong>{new Intl.DateTimeFormat('he-IL', { dateStyle: 'long' }).format(new Date(`${deliveryDateKey(selected)}T12:00:00`))}</strong><p>{deliveryTimingText(selected)}</p><p>נוצר {dateFormat.format(new Date(selected.createdAt))} · {selected.notes || 'ללא הערות'}</p></div></div>{selected.syncError && <p className="alert error">{selected.syncError}</p>}<DeliveryActions key={selected.id} delivery={selected} customers={customers} addresses={addresses} conflict={outbox.find(e => e.operation.entityId === selected.id && e.serverConflict)} onSaved={() => { setNotice('השינוי נשמר במכשיר'); if (navigator.onLine) void syncNow(); }} /></section>}
|
||||||
|
</> : view === 'customers' ? <CustomersView customers={customers} addresses={addresses} deliveries={deliveries} outbox={outbox} busy={saving} setBusy={setSaving} save={saveEntity} createRequest={customerCreateRequest} onChanged={message => { setNotice(message); if (navigator.onLine) void syncNow(); }} /> : <CostsView deliveries={deliveries} customers={customers} />}
|
||||||
|
<footer>{offlineReady ? 'האפליקציה מוכנה לעבודה ללא חיבור' : 'לשימוש ראשון ללא חיבור יש לטעון את האפליקציה כשהרשת זמינה'}<span>מודל · גרסה ראשונית</span></footer>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
|
||||||
|
import type { Session, User } from '@model/shared';
|
||||||
|
import { BrandLogo } from './BrandLogo';
|
||||||
|
|
||||||
|
const cacheKey = 'model-delivery-session-profile';
|
||||||
|
const loadApp = () => import('./App').then(module => ({ default: module.App }));
|
||||||
|
const App = lazy(loadApp);
|
||||||
|
async function readCachedSession(): Promise<Session | null> {
|
||||||
|
try {
|
||||||
|
const { sessionSchema } = await import('@model/shared');
|
||||||
|
const parsed = sessionSchema.safeParse(JSON.parse(localStorage.getItem(cacheKey) ?? 'null'));
|
||||||
|
return parsed.success && Date.parse(parsed.data.expiresAt) > Date.now() ? parsed.data : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
export function AuthGate() {
|
||||||
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [ssoConfigured, setSsoConfigured] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [revision, setRevision] = useState(0);
|
||||||
|
const generation = useRef(0);
|
||||||
|
const activeUser = useRef<string | undefined>(undefined);
|
||||||
|
|
||||||
|
async function activate(next: Session, save = true) {
|
||||||
|
if (activeUser.current !== next.user.id) { const { selectAccount } = await import('./db'); await selectAccount(next.user); activeUser.current = next.user.id; }
|
||||||
|
if (save) localStorage.setItem(cacheKey, JSON.stringify(next));
|
||||||
|
setSession(next); setLoading(false); setError('');
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
const current = ++generation.current;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), 6000);
|
||||||
|
setLoading(true);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const responsePromise = fetch('/api/auth/session', { signal: controller.signal });
|
||||||
|
// Begin the workspace download after the session request has entered the
|
||||||
|
// network queue, so authenticated startup can proceed in parallel.
|
||||||
|
const appPromise = loadApp();
|
||||||
|
void appPromise.catch(() => undefined);
|
||||||
|
const response = await responsePromise;
|
||||||
|
if (!response.ok) throw new Error('server unavailable');
|
||||||
|
const payload = await response.json();
|
||||||
|
if (current !== generation.current) return;
|
||||||
|
if (payload.user) { const { sessionSchema } = await import('@model/shared'); await appPromise; await activate(sessionSchema.parse(payload)); }
|
||||||
|
else {
|
||||||
|
localStorage.removeItem(cacheKey); setSession(null); setSsoConfigured(payload.ssoConfigured === true); setLoading(false);
|
||||||
|
const authError = new URLSearchParams(location.search).get('authError');
|
||||||
|
if (authError) {
|
||||||
|
setError(authError === 'group_denied' ? 'החשבון אינו חבר בקבוצה המורשית לאפליקציה. יש לפנות למנהל המערכת.' : 'הכניסה לא הושלמה. ניתן לנסות שוב.'); history.replaceState(null, '', location.pathname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (current !== generation.current) return;
|
||||||
|
const cached = await readCachedSession();
|
||||||
|
if (cached) {
|
||||||
|
try { await activate(cached, false); }
|
||||||
|
catch { setError('לא ניתן לפתוח את האחסון המקומי במכשיר.'); setLoading(false); }
|
||||||
|
} else {
|
||||||
|
setSession(null); setError('נדרש חיבור לשרת כדי להתחבר. השינויים השמורים לא נמחקו.'); setLoading(false);
|
||||||
|
}
|
||||||
|
} finally { window.clearTimeout(timeout); }
|
||||||
|
})();
|
||||||
|
return () => { window.clearTimeout(timeout); controller.abort(); generation.current++; };
|
||||||
|
}, [revision]);
|
||||||
|
useEffect(() => {
|
||||||
|
const reload = () => setRevision(v => v + 1);
|
||||||
|
const storage = (event: StorageEvent) => { if (event.key === cacheKey) reload(); };
|
||||||
|
window.addEventListener('model-session-expired', reload);
|
||||||
|
window.addEventListener('storage', storage);
|
||||||
|
return () => { window.removeEventListener('model-session-expired', reload); window.removeEventListener('storage', storage); };
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session) return;
|
||||||
|
const timer = window.setTimeout(() => { localStorage.removeItem(cacheKey); setSession(null); setError('תוקף הכניסה הסתיים. יש להתחבר מחדש; השינויים נשמרו.'); }, Math.max(0, Date.parse(session.expiresAt) - Date.now()));
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
async function signIn() {
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'x-model-request': '1' }, signal: AbortSignal.timeout(15000) });
|
||||||
|
const body = await response.json();
|
||||||
|
if (!response.ok) throw new Error(response.status === 429 ? 'בוצעו ניסיונות רבים. נסו שוב בעוד דקה.' : 'שרת ההזדהות אינו זמין כרגע. נסו שוב בהמשך.');
|
||||||
|
window.location.assign(body.url);
|
||||||
|
} catch (failure) { setError(failure instanceof TypeError ? 'אין חיבור לשרת. נסו שוב כשהחיבור זמין.' : failure instanceof Error ? failure.message : 'לא ניתן להתחבר'); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
async function logout() {
|
||||||
|
const response = await fetch('/api/auth/logout', { method: 'POST', headers: { 'x-model-request': '1' }, signal: AbortSignal.timeout(6000) });
|
||||||
|
if (!response.ok) throw new Error('לא ניתן לצאת כרגע. יש לנסות שוב כשהשרת זמין.');
|
||||||
|
localStorage.removeItem(cacheKey); activeUser.current = undefined; setSession(null); setRevision(v => v + 1);
|
||||||
|
// Cached operational data stays in this user's DB, including any unsent operations.
|
||||||
|
}
|
||||||
|
function updateProfile(user: User) {
|
||||||
|
if (!session || session.user.id !== user.id || activeUser.current !== user.id) return;
|
||||||
|
const next = { ...session, user };
|
||||||
|
localStorage.setItem(cacheKey, JSON.stringify(next));
|
||||||
|
setSession(next);
|
||||||
|
}
|
||||||
|
if (loading) return <div className="auth-page"><div className="auth-card" role="status">טוען את סביבת העבודה…</div></div>;
|
||||||
|
if (session) return <Suspense fallback={<div className="auth-page"><div className="auth-card" role="status">פותח את סביבת העבודה…</div></div>}><App key={session.user.id} user={session.user} onLogout={logout} onProfileChange={updateProfile} /></Suspense>;
|
||||||
|
return <main className="auth-page"><section className="auth-card">
|
||||||
|
<div className="auth-brand"><BrandLogo className="auth-logo" /><div><h1>מודל משלוחים</h1><p>ברוכים הבאים לסביבת העבודה של המעבדה.</p></div></div>
|
||||||
|
<h2>כניסה לחשבון</h2>
|
||||||
|
{error && <p className="alert error" role="alert">{error}</p>}
|
||||||
|
<button className="primary" disabled={busy || !ssoConfigured} onClick={() => void signIn()}>{busy ? 'מעביר לכניסה…' : 'כניסה עם חשבון המעבדה'}</button>
|
||||||
|
{!ssoConfigured && <p className="helper">הכניסה הארגונית עדיין לא הוגדרה. יש לפנות למנהל המערכת.</p>}
|
||||||
|
<button className="auth-refresh" onClick={() => setRevision(v => v + 1)}>בדיקת חיבור מחדש</button>
|
||||||
|
<p className="helper">לאחר הכניסה ניתן להמשיך לעבוד במכשיר גם ללא חיבור. יציאה מהחשבון שומרת את השינויים עד לכניסה הבאה.</p>
|
||||||
|
</section></main>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function BrandLogo({ className = '', alt = 'Model Dental Lab' }: { className?: string; alt?: string }) {
|
||||||
|
return <picture className={`brand-logo ${className}`.trim()}>
|
||||||
|
<source srcSet="/brand-logo.webp" type="image/webp" />
|
||||||
|
<img src="/brand-logo.png" width="417" height="250" alt={alt} />
|
||||||
|
</picture>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { LocalCustomer, LocalDelivery } from './db';
|
||||||
|
import { activeDeliveries, deliveryDateKey, toDateKey, valueOf, weekDates } from './analytics';
|
||||||
|
|
||||||
|
const currency = new Intl.NumberFormat('he-IL', { style: 'currency', currency: 'ILS' });
|
||||||
|
const monthTitle = new Intl.DateTimeFormat('he-IL', { month: 'long', year: 'numeric' });
|
||||||
|
|
||||||
|
export function CostsView({ deliveries, customers }: { deliveries: LocalDelivery[]; customers: LocalCustomer[] }) {
|
||||||
|
const now = new Date();
|
||||||
|
const today = toDateKey(now);
|
||||||
|
const week = new Set(weekDates(now).map(toDateKey));
|
||||||
|
const monthPrefix = today.slice(0, 7);
|
||||||
|
const current = activeDeliveries(deliveries);
|
||||||
|
const todayItems = current.filter(delivery => deliveryDateKey(delivery) === today);
|
||||||
|
const weekItems = current.filter(delivery => week.has(deliveryDateKey(delivery)));
|
||||||
|
const monthItems = current.filter(delivery => deliveryDateKey(delivery).startsWith(monthPrefix));
|
||||||
|
const byCustomer = customers.filter(customer => !customer.deletedAt || current.some(delivery => delivery.customerId === customer.id)).map(customer => {
|
||||||
|
const items = monthItems.filter(delivery => delivery.customerId === customer.id);
|
||||||
|
return { customer, items, total: valueOf(items) };
|
||||||
|
}).filter(entry => entry.items.length).sort((a, b) => b.total - a.total);
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<section className="dashboard-metrics costs-metrics" aria-label="סיכום עלויות">
|
||||||
|
<article className="summary-card featured"><span>היום</span><strong>{currency.format(valueOf(todayItems) / 100)}</strong><small>{todayItems.length} משלוחים</small></article>
|
||||||
|
<article className="summary-card"><span>השבוע</span><strong>{currency.format(valueOf(weekItems) / 100)}</strong><small>{weekItems.length} משלוחים</small></article>
|
||||||
|
<article className="summary-card"><span>{monthTitle.format(now)}</span><strong>{currency.format(valueOf(monthItems) / 100)}</strong><small>{monthItems.length} משלוחים</small></article>
|
||||||
|
<article className="summary-card"><span>ממוצע למשלוח החודש</span><strong>{currency.format(monthItems.length ? valueOf(monthItems) / monthItems.length / 100 : 0)}</strong><small>משלוחים פעילים בלבד</small></article>
|
||||||
|
</section>
|
||||||
|
<section className="glass-panel costs-panel">
|
||||||
|
<div className="section-toolbar"><div><p className="eyebrow">פירוט לפי לקוח</p><h2>{monthTitle.format(now)}</h2></div><span className="count">{byCustomer.length} לקוחות</span></div>
|
||||||
|
<p className="costs-explainer">הסכומים מחושבים ממחירי המשלוחים שנשמרו במערכת. משלוחים שבוטלו אינם נכללים.</p>
|
||||||
|
{!byCustomer.length ? <div className="empty"><span className="empty-mark" aria-hidden="true">₪</span><h3>עדיין אין עלויות לחודש הזה</h3><p>לאחר שמירת משלוחים, הפירוט יופיע כאן אוטומטית.</p></div> : <div className="cost-table" role="table" aria-label="עלויות לפי לקוח">
|
||||||
|
<div className="cost-row header" role="row"><span role="columnheader">לקוח</span><span role="columnheader">משלוחים</span><span role="columnheader">סה״כ</span></div>
|
||||||
|
{byCustomer.map(({ customer, items, total }) => <div className="cost-row" role="row" key={customer.id}><strong role="cell">{customer.name}</strong><span role="cell">{items.length}</span><strong role="cell">{currency.format(total / 100)}</strong></div>)}
|
||||||
|
</div>}
|
||||||
|
</section>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||||
|
import type { Customer, CustomerAddress } from '@model/shared';
|
||||||
|
import { changeCustomer, resolveCustomerConflict, type LocalAddress, type LocalCustomer, type LocalDelivery, type OutboxEntry } from './db';
|
||||||
|
|
||||||
|
type CustomerData = Omit<Customer, 'id' | 'version' | 'createdAt' | 'deletedAt' | 'updatedAt'>;
|
||||||
|
const windowLabels = { morning: 'בוקר', afternoon: 'צהריים', lateNight: 'לילה' } as const;
|
||||||
|
const windowDefaults = { morning: ['08:00', '12:00'], afternoon: ['12:00', '17:00'], lateNight: ['20:00', '23:00'] } as const;
|
||||||
|
function dataFromForm(form: HTMLFormElement): CustomerData {
|
||||||
|
const fields = new FormData(form);
|
||||||
|
const deliveryWindows = Object.fromEntries(Object.entries(windowDefaults).map(([key, defaults]) => [key, fields.get(`${key}Enabled`) === 'on' ? { start: String(fields.get(`${key}Start`) || defaults[0]), end: String(fields.get(`${key}End`) || defaults[1]) } : null])) as CustomerData['deliveryWindows'];
|
||||||
|
return { name: String(fields.get('name') ?? ''), contactName: String(fields.get('contactName') ?? ''), phone: String(fields.get('phone') ?? ''), comments: String(fields.get('comments') ?? ''), deliveryWindows };
|
||||||
|
}
|
||||||
|
function CustomerForm({ customer, busy, submit, cancel }: { customer?: LocalCustomer; busy: boolean; submit: (data: CustomerData) => Promise<void>; cancel?: () => void }) {
|
||||||
|
return <form className="customer-form" onSubmit={event => { event.preventDefault(); void submit(dataFromForm(event.currentTarget)); }}>
|
||||||
|
<div className="field-pair"><label>שם הלקוח<input name="name" required maxLength={120} defaultValue={customer?.name} placeholder="שם המרפאה או הלקוח" /></label><label>איש קשר<input name="contactName" maxLength={100} defaultValue={customer?.contactName} autoComplete="name" placeholder="שם מלא" /></label></div>
|
||||||
|
<label>מספר טלפון<input name="phone" dir="ltr" inputMode="tel" maxLength={30} pattern="[0-9+().\-\s]*" defaultValue={customer?.phone} autoComplete="tel" placeholder="050-0000000" /></label>
|
||||||
|
<fieldset className="delivery-windows"><legend>חלונות מסירה מועדפים</legend>{(Object.keys(windowLabels) as Array<keyof typeof windowLabels>).map(key => {
|
||||||
|
const value = customer?.deliveryWindows[key]; const defaults = windowDefaults[key];
|
||||||
|
return <div className="window-row" key={key}><label className="check-field"><input name={`${key}Enabled`} type="checkbox" defaultChecked={Boolean(value)} />{windowLabels[key]}</label><label><span className="sr-only">תחילת {windowLabels[key]}</span><input aria-label={`תחילת ${windowLabels[key]}`} name={`${key}Start`} type="time" defaultValue={value?.start ?? defaults[0]} /></label><span>עד</span><label><span className="sr-only">סיום {windowLabels[key]}</span><input aria-label={`סיום ${windowLabels[key]}`} name={`${key}End`} type="time" defaultValue={value?.end ?? defaults[1]} /></label></div>;
|
||||||
|
})}</fieldset>
|
||||||
|
<label>הערות ללקוח <span className="optional">לא חובה</span><textarea name="comments" rows={3} maxLength={1000} defaultValue={customer?.comments} placeholder="הנחיות קבועות, חניה, קומה או פרטים חשובים" /></label>
|
||||||
|
<div className="form-actions"><button className="primary" disabled={busy}>{busy ? 'שומר…' : customer ? 'שמירת שינויים' : 'יצירת לקוח'}</button>{cancel && <button className="secondary" type="button" onClick={cancel}>ביטול</button>}</div>
|
||||||
|
</form>;
|
||||||
|
}
|
||||||
|
function AddressText({ address }: { address: CustomerAddress | LocalAddress }) {
|
||||||
|
return <>{address.street ? `${address.street} ${address.buildingNumber}, ${address.city}` : address.address}</>;
|
||||||
|
}
|
||||||
|
export function CustomersView({ customers, addresses, deliveries, outbox, busy, setBusy, save, onChanged, createRequest = 0 }: {
|
||||||
|
customers: LocalCustomer[]; addresses: LocalAddress[]; deliveries: LocalDelivery[]; outbox: OutboxEntry[]; busy: boolean;
|
||||||
|
setBusy: (value: boolean) => void; save: (kind: 'customer.create' | 'address.create', data: unknown) => Promise<string>; onChanged: (message: string) => void; createRequest?: number;
|
||||||
|
}) {
|
||||||
|
const [selectedId, setSelectedId] = useState<string>();
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const visible = useMemo(() => customers.filter(customer => showArchived || !customer.deletedAt), [customers, showArchived]);
|
||||||
|
const selectedCandidate = customers.find(customer => customer.id === selectedId);
|
||||||
|
const selected = selectedId ? (selectedCandidate && (showArchived || !selectedCandidate.deletedAt) ? selectedCandidate : undefined) : visible[0];
|
||||||
|
const relatedAddresses = addresses.filter(address => address.customerId === selected?.id && !address.deletedAt);
|
||||||
|
const relatedDeliveries = deliveries.filter(delivery => delivery.customerId === selected?.id && !delivery.deletedAt);
|
||||||
|
const conflict = outbox.find(entry => entry.operation.entityId === selected?.id && entry.serverConflict?.entityType === 'customer');
|
||||||
|
// A Dexie live query may publish the new row just after save() resolves. Do not
|
||||||
|
// replace an explicitly selected new ID with the old first row during that gap.
|
||||||
|
useEffect(() => { if (!selectedId && selected) setSelectedId(selected.id); }, [selected, selectedId]);
|
||||||
|
useEffect(() => { if (createRequest > 0) { setCreating(true); setEditing(false); setConfirmRemove(false); setError(''); } }, [createRequest]);
|
||||||
|
async function create(data: CustomerData) {
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try { const id = await save('customer.create', data); setSelectedId(id); setCreating(false); onChanged('הלקוח נשמר במכשיר'); }
|
||||||
|
catch (failure) { setError(failure instanceof Error ? failure.message : 'לא ניתן לשמור את הלקוח.'); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
async function update(data: CustomerData) {
|
||||||
|
if (!selected) return; setBusy(true); setError('');
|
||||||
|
try { await changeCustomer(selected.id, selected.version, 'customer.update', data); setEditing(false); onChanged('פרטי הלקוח נשמרו במכשיר'); }
|
||||||
|
catch (failure) { setError(failure instanceof Error ? failure.message : 'לא ניתן לעדכן את הלקוח.'); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
async function archive() {
|
||||||
|
if (!selected) return; setBusy(true); setError('');
|
||||||
|
try { await changeCustomer(selected.id, selected.version, 'customer.remove', {}); setConfirmRemove(false); setSelectedId(undefined); onChanged('הלקוח הועבר לארכיון. הרשומות המשויכות נשמרו.'); }
|
||||||
|
catch (failure) { setError(failure instanceof Error ? failure.message : 'לא ניתן להעביר את הלקוח לארכיון.'); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
async function addAddress(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault(); if (!selected) return;
|
||||||
|
const form = event.currentTarget, fields = new FormData(form); const city = String(fields.get('city') ?? ''), street = String(fields.get('street') ?? ''), buildingNumber = String(fields.get('buildingNumber') ?? '');
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try { await save('address.create', { customerId: selected.id, label: fields.get('label'), city, street, buildingNumber, address: `${street} ${buildingNumber}, ${city}`.trim() }); form.reset(); onChanged('הכתובת נשמרה במכשיר'); }
|
||||||
|
catch (failure) { setError(failure instanceof Error ? failure.message : 'לא ניתן לשמור את הכתובת.'); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
async function resolve(choice: 'server' | 'local') {
|
||||||
|
if (!conflict?.serverConflict) return; setBusy(true); setError('');
|
||||||
|
try { await resolveCustomerConflict(conflict.operationId, conflict.serverConflict.record.version, choice); onChanged(choice === 'server' ? 'גרסת השרת נשמרה' : 'השינוי שלך הוכן לסנכרון מחדש'); }
|
||||||
|
catch (failure) { setError(failure instanceof Error ? failure.message : 'לא ניתן לפתור את ההתנגשות.'); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
return <div className="clients-layout">
|
||||||
|
<section className="glass-panel clients-list"><div className="panel-heading"><div><p className="eyebrow">לקוחות</p><h2>ספר לקוחות</h2></div><button className="icon-button" aria-label="לקוח חדש" onClick={() => setCreating(true)}>+</button></div>
|
||||||
|
<div className="client-list-controls"><label className="check-field"><input type="checkbox" checked={showArchived} onChange={event => setShowArchived(event.target.checked)} />הצגת ארכיון</label><span className="count">{visible.length}</span></div>
|
||||||
|
<div className="client-list-scroll">{visible.map(customer => <button key={customer.id} className={`client-row ${selected?.id === customer.id ? 'selected' : ''}`} onClick={() => { setSelectedId(customer.id); setCreating(false); setEditing(false); }}><span className="client-avatar">{customer.name.slice(0, 1)}</span><span><strong>{customer.name}</strong><small>{customer.deletedAt ? 'בארכיון' : customer.contactName || customer.phone || 'ללא איש קשר'}</small></span><span className={`sync-dot ${customer.syncState}`} aria-label={customer.syncState} /></button>)}</div>
|
||||||
|
</section>
|
||||||
|
<section className="glass-panel client-detail">
|
||||||
|
{creating ? <><div className="panel-heading"><div><p className="eyebrow">כרטיס חדש</p><h2>הוספת לקוח</h2></div></div>{error && <p className="alert error" role="alert">{error}</p>}<CustomerForm busy={busy} submit={create} cancel={() => setCreating(false)} /></> : !selected ? <div className="empty"><span className="empty-mark">◎</span><h3>בחרו לקוח</h3><p>או צרו לקוח חדש כדי להתחיל.</p><button className="primary compact" onClick={() => setCreating(true)}>לקוח חדש</button></div> : <>
|
||||||
|
<div className="client-hero"><span className="client-avatar large">{selected.name.slice(0, 1)}</span><div><p className="eyebrow">כרטיס לקוח</p><h2>{selected.name}</h2><p>{selected.contactName || 'טרם הוגדר איש קשר'}</p></div><div className="client-actions">{!selected.deletedAt && <><button className="secondary" disabled={busy || selected.syncState !== 'synced'} onClick={() => setEditing(!editing)}>עריכה</button><button className="danger-button" disabled={busy || selected.syncState !== 'synced'} onClick={() => setConfirmRemove(true)}>הסרה</button></>}</div></div>
|
||||||
|
{error && <p className="alert error" role="alert">{error}</p>}
|
||||||
|
{conflict?.serverConflict?.entityType === 'customer' && <div className="conflict-panel" role="region" aria-label="פתרון התנגשות לקוח"><h3>הלקוח השתנה במכשיר אחר</h3><p>בחרו איזו גרסה לשמור לאחר בדיקת הפרטים.</p><div className="action-buttons"><button className="secondary" disabled={busy} onClick={() => void resolve('server')}>שמירת גרסת השרת</button><button className="primary compact" disabled={busy} onClick={() => void resolve('local')}>החלת השינוי שלי</button></div></div>}
|
||||||
|
{editing ? <CustomerForm key={`${selected.id}-${selected.version}`} customer={selected} busy={busy} submit={update} cancel={() => setEditing(false)} /> : <div className="client-summary">
|
||||||
|
<div><span>איש קשר</span><strong>{selected.contactName || 'לא הוגדר'}</strong></div><div><span>טלפון</span>{selected.phone ? <a dir="ltr" href={`tel:${selected.phone}`}>{selected.phone}</a> : <strong>לא הוגדר</strong>}</div>
|
||||||
|
<div className="wide"><span>חלונות מסירה</span><div className="slot-chips">{(Object.keys(windowLabels) as Array<keyof typeof windowLabels>).map(key => selected.deliveryWindows[key] && <span key={key}>{windowLabels[key]} · {selected.deliveryWindows[key]!.start}–{selected.deliveryWindows[key]!.end}</span>)}{!Object.values(selected.deliveryWindows).some(Boolean) && <strong>לא הוגדרו</strong>}</div></div>
|
||||||
|
<div className="wide"><span>הערות</span><p>{selected.comments || 'ללא הערות קבועות'}</p></div>
|
||||||
|
</div>}
|
||||||
|
{confirmRemove && <div className="archive-confirm" role="alert"><strong>להעביר את {selected.name} לארכיון?</strong><p>{relatedDeliveries.length} משלוחים ו־{relatedAddresses.length} כתובות יישמרו ויישארו משויכים.</p><div className="action-buttons"><button className="danger-button" disabled={busy} onClick={() => void archive()}>העברה לארכיון</button><button className="secondary" onClick={() => setConfirmRemove(false)}>ביטול</button></div></div>}
|
||||||
|
<section className="associated-section"><div className="section-title"><h3>כתובות</h3><span>{relatedAddresses.length}</span></div>{!selected.deletedAt && <form className="inline-address-form" onSubmit={event => void addAddress(event)}><label>שם הכתובת<input name="label" required maxLength={80} placeholder="ראשית" /></label><label>עיר<input name="city" required maxLength={100} autoComplete="address-level2" /></label><label>רחוב<input name="street" required maxLength={120} autoComplete="address-line1" /></label><label>מספר<input name="buildingNumber" required maxLength={20} inputMode="text" /></label><button className="primary compact" disabled={busy}>הוספת כתובת</button></form>}{relatedAddresses.map(address => <article className="associated-row" key={address.id}><div><strong>{address.label}</strong><p><AddressText address={address} /></p></div><span className={`sync-dot ${address.syncState}`} /></article>)}</section>
|
||||||
|
<section className="associated-section"><div className="section-title"><h3>רשומות משויכות</h3><span>{relatedDeliveries.length}</span></div>{!relatedDeliveries.length ? <p className="helper">אין משלוחים משויכים ללקוח.</p> : relatedDeliveries.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt)).map(delivery => <article className="associated-row" key={delivery.id}><div><strong>{delivery.deliveryNumber ? `משלוח #${delivery.deliveryNumber}` : 'משלוח ממתין לסנכרון'}</strong><p>{new Date(delivery.createdAt).toLocaleDateString('he-IL')} · {delivery.status === 'cancelled' ? 'מבוטל' : 'פעיל'}</p></div><span className={`sync-dot ${delivery.syncState}`} /></article>)}</section>
|
||||||
|
</>}
|
||||||
|
</section>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { LocalAddress, LocalCustomer, LocalDelivery } from './db';
|
||||||
|
import { activeDeliveries, addDays, deliveriesForDate, toDateKey, valueOf, weekDates, weekStart } from './analytics';
|
||||||
|
import { deliveryTimingText } from './deliveryTiming';
|
||||||
|
|
||||||
|
const currency = new Intl.NumberFormat('he-IL', { style: 'currency', currency: 'ILS', maximumFractionDigits: 0 });
|
||||||
|
const fullDate = new Intl.DateTimeFormat('he-IL', { weekday: 'long', day: 'numeric', month: 'long' });
|
||||||
|
const dayName = new Intl.DateTimeFormat('he-IL', { weekday: 'short' });
|
||||||
|
const dayNumber = new Intl.DateTimeFormat('he-IL', { day: 'numeric' });
|
||||||
|
const weekLabel = new Intl.DateTimeFormat('he-IL', { day: 'numeric', month: 'short' });
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
deliveries: LocalDelivery[];
|
||||||
|
customers: LocalCustomer[];
|
||||||
|
addresses: LocalAddress[];
|
||||||
|
onOpenDeliveries: (date: string) => void;
|
||||||
|
onNewDelivery: () => void;
|
||||||
|
onNewCustomer: () => void;
|
||||||
|
onOpenCosts: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DashboardView({ deliveries, customers, addresses, onOpenDeliveries, onNewDelivery, onNewCustomer, onOpenCosts }: Props) {
|
||||||
|
const today = toDateKey(new Date());
|
||||||
|
const [anchor, setAnchor] = useState(() => new Date());
|
||||||
|
const [selectedDate, setSelectedDate] = useState(today);
|
||||||
|
const days = weekDates(anchor);
|
||||||
|
const weekKeys = new Set(days.map(toDateKey));
|
||||||
|
const weekDeliveries = deliveries.filter(delivery => weekKeys.has(delivery.scheduledDate ?? delivery.createdAt.slice(0, 10)));
|
||||||
|
const todayDeliveries = deliveriesForDate(deliveries, today);
|
||||||
|
const selectedDeliveries = deliveriesForDate(deliveries, selectedDate);
|
||||||
|
const selectedDateObject = days.find(day => toDateKey(day) === selectedDate) ?? new Date();
|
||||||
|
const moveWeek = (offset: number) => {
|
||||||
|
const next = addDays(weekStart(anchor), offset * 7);
|
||||||
|
setAnchor(next);
|
||||||
|
setSelectedDate(toDateKey(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<section className="quick-actions" aria-label="פעולות מהירות">
|
||||||
|
<button className="quick-action primary-action" onClick={onNewDelivery}><span className="quick-icon" aria-hidden="true">+</span><span><strong>משלוח חדש</strong><small>בחירת לקוח, כתובת ותאריך</small></span><b aria-hidden="true">←</b></button>
|
||||||
|
<button className="quick-action" onClick={onNewCustomer}><span className="quick-icon" aria-hidden="true">◎</span><span><strong>לקוח חדש</strong><small>פרטי קשר, כתובת וחלונות מסירה</small></span><b aria-hidden="true">←</b></button>
|
||||||
|
<button className="quick-action" onClick={() => onOpenDeliveries(today)}><span className="quick-icon" aria-hidden="true">▤</span><span><strong>המשלוחים של היום</strong><small>{activeDeliveries(todayDeliveries).length} משלוחים פעילים</small></span><b aria-hidden="true">←</b></button>
|
||||||
|
<button className="quick-action" onClick={onOpenCosts}><span className="quick-icon" aria-hidden="true">₪</span><span><strong>סיכום עלויות</strong><small>יום, שבוע, חודש ולפי לקוח</small></span><b aria-hidden="true">←</b></button>
|
||||||
|
</section>
|
||||||
|
<section className="dashboard-metrics" aria-label="סיכום יומי ושבועי">
|
||||||
|
<article className="summary-card featured"><span>מתוכננים להיום</span><strong>{activeDeliveries(todayDeliveries).length}</strong><small>{currency.format(valueOf(todayDeliveries) / 100)}</small></article>
|
||||||
|
<article className="summary-card"><span>השבוע</span><strong>{activeDeliveries(weekDeliveries).length}</strong><small>משלוחים פעילים</small></article>
|
||||||
|
<article className="summary-card"><span>ערך השבוע</span><strong>{currency.format(valueOf(weekDeliveries) / 100)}</strong><small>לפי המחירים השמורים</small></article>
|
||||||
|
<article className="summary-card"><span>לקוחות פעילים</span><strong>{customers.filter(customer => !customer.deletedAt).length}</strong><small>ברשימת הלקוחות</small></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="glass-panel week-board" aria-label="לוח משלוחים שבועי">
|
||||||
|
<div className="section-toolbar">
|
||||||
|
<div><p className="eyebrow">תכנון שבועי</p><h2>{weekLabel.format(days[0])}–{weekLabel.format(days[6])}</h2></div>
|
||||||
|
<div className="week-controls"><button className="icon-button" aria-label="השבוע הקודם" onClick={() => moveWeek(-1)}>‹</button><button className="secondary" onClick={() => { setAnchor(new Date()); setSelectedDate(today); }}>השבוע</button><button className="icon-button" aria-label="השבוע הבא" onClick={() => moveWeek(1)}>›</button></div>
|
||||||
|
</div>
|
||||||
|
<div className="calendar-carousel">
|
||||||
|
{days.map(day => {
|
||||||
|
const key = toDateKey(day);
|
||||||
|
const items = deliveriesForDate(deliveries, key);
|
||||||
|
const active = activeDeliveries(items);
|
||||||
|
return <button key={key} className={`day-card ${key === today ? 'today' : ''}`} aria-pressed={selectedDate === key} onClick={() => setSelectedDate(key)}>
|
||||||
|
<span>{dayName.format(day)}</span><strong>{dayNumber.format(day)}</strong><b>{active.length}</b><small>{active.length === 1 ? 'משלוח' : 'משלוחים'}</small>
|
||||||
|
</button>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="day-summary">
|
||||||
|
<div className="day-summary-heading"><div><span>{fullDate.format(selectedDateObject)}</span><strong>{activeDeliveries(selectedDeliveries).length} משלוחים פעילים</strong></div><button className="primary compact" onClick={() => onOpenDeliveries(selectedDate)}>למשלוחים ביום זה</button></div>
|
||||||
|
{!selectedDeliveries.length ? <p className="empty-day">לא נקבעו משלוחים ליום הזה.</p> : <div className="dashboard-delivery-list">
|
||||||
|
{selectedDeliveries.slice(0, 5).map(delivery => <article key={delivery.id} className={delivery.status === 'cancelled' ? 'cancelled' : ''}><span className="delivery-number">{delivery.deliveryNumber ? `#${delivery.deliveryNumber}` : 'חדש'}</span><div><strong>{customers.find(customer => customer.id === delivery.customerId)?.name ?? 'לקוח'}</strong><p>{addresses.find(address => address.id === delivery.addressId)?.address ?? 'כתובת לא זמינה'} · {deliveryTimingText(delivery)}</p></div><span>{delivery.status === 'cancelled' ? 'מבוטל' : currency.format(delivery.totalPriceAgorot / 100)}</span></article>)}
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import type { DeliverySlot } from '@model/shared';
|
||||||
|
import { changeDelivery, resolveConflict, type LocalAddress, type LocalCustomer, type LocalDelivery, type OutboxEntry } from './db';
|
||||||
|
import { deliverySlotOptions, deliveryTimingInput, deliveryTimingText, windowText } from './deliveryTiming';
|
||||||
|
|
||||||
|
type Props = { delivery: LocalDelivery; customers: LocalCustomer[]; addresses: LocalAddress[]; conflict?: OutboxEntry; onSaved: () => void };
|
||||||
|
type Draft = { customerId: string; addressId: string; scheduledDate: string; deliverySlot: DeliverySlot; specificTime: string; notes: string; baseVersion: number };
|
||||||
|
function draftFrom(delivery: LocalDelivery): Draft {
|
||||||
|
return {
|
||||||
|
customerId: delivery.customerId,
|
||||||
|
addressId: delivery.addressId,
|
||||||
|
scheduledDate: delivery.scheduledDate ?? delivery.createdAt.slice(0, 10),
|
||||||
|
deliverySlot: delivery.deliverySlot ?? 'morning',
|
||||||
|
specificTime: delivery.specificTime ?? '09:00',
|
||||||
|
notes: delivery.notes,
|
||||||
|
baseVersion: delivery.version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function DeliveryActions({ delivery, customers, addresses, conflict, onSaved }: Props) {
|
||||||
|
const [mode, setMode] = useState<'view' | 'edit' | 'cancel'>('view');
|
||||||
|
const [draft, setDraft] = useState<Draft>(() => draftFrom(delivery));
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const server = conflict?.serverConflict?.entityType === 'delivery' ? conflict.serverConflict.record : undefined;
|
||||||
|
const editable = delivery.syncState === 'synced' && delivery.status !== 'cancelled';
|
||||||
|
async function save(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault(); setBusy(true); setError('');
|
||||||
|
const fields = new FormData(event.currentTarget);
|
||||||
|
try {
|
||||||
|
const selectedCustomer = customers.find(customer => customer.id === draft.customerId);
|
||||||
|
await changeDelivery(delivery.id, draft.baseVersion, mode === 'cancel' ? 'delivery.cancel' : 'delivery.update', mode === 'cancel' ? { reason: fields.get('reason') } : { customerId: draft.customerId, addressId: draft.addressId, scheduledDate: draft.scheduledDate, ...deliveryTimingInput(selectedCustomer, draft.deliverySlot, draft.specificTime), notes: draft.notes });
|
||||||
|
setMode('view'); onSaved();
|
||||||
|
} catch (failure) { setError(failure instanceof Error && !failure.message.startsWith('[') ? failure.message : 'לא ניתן לשמור. יש לבדוק את הפרטים.'); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
async function resolve(choice: 'server' | 'local') {
|
||||||
|
if (!conflict || !server) return;
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try { await resolveConflict(conflict.operationId, server.version, choice); setMode('view'); onSaved(); }
|
||||||
|
catch (failure) { setError(failure instanceof Error ? failure.message : 'לא ניתן לפתור את ההתנגשות'); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
return <div className="delivery-actions">
|
||||||
|
{delivery.status === 'cancelled' && <div className="cancelled-notice"><strong>משלוח מבוטל</strong><p>{delivery.cancellationReason}</p><span>מספר המשלוח והמחיר המקורי נשמרו.</span></div>}
|
||||||
|
{server && conflict && <section className="conflict-panel" aria-label="פתרון התנגשות">
|
||||||
|
<h3>המשלוח השתנה במכשיר אחר</h3><p>בדקו את שתי הגרסאות ובחרו איך להמשיך.</p>
|
||||||
|
<div className="conflict-versions">{[{ label: 'הגרסה שלי', record: delivery }, { label: `גרסת השרת · ${server.version}`, record: server }].map(({ label, record }) => <div key={label}><h4>{label}</h4><p><strong>לקוח: </strong>{customers.find(c => c.id === record.customerId)?.name ?? record.customerId}</p><p><strong>כתובת: </strong>{addresses.find(a => a.id === record.addressId)?.address ?? record.addressId}</p><p><strong>תאריך מתוכנן: </strong>{record.scheduledDate ?? record.createdAt.slice(0, 10)}</p><p><strong>מועד מסירה: </strong>{deliveryTimingText(record)}</p><p><strong>הערות: </strong>{record.notes || 'ללא הערות'}</p><p><strong>מצב: </strong>{record.status === 'cancelled' ? 'מבוטל' : 'פעיל'}</p>{record.cancellationReason && <p><strong>סיבת ביטול: </strong>{record.cancellationReason}</p>}</div>)}</div>
|
||||||
|
<div className="action-buttons"><button className="secondary" disabled={busy} onClick={() => void resolve('server')}>שימוש בגרסת השרת</button><button className="primary" disabled={busy || server.status === 'cancelled' || Boolean(server.deletedAt) || conflict.operation.kind === 'delivery.create'} onClick={() => void resolve('local')}>החלת השינוי שלי על הגרסה החדשה</button></div>
|
||||||
|
<p className="helper">בחירת גרסת השרת מוותרת על השינוי המקומי. החלת השינוי שלי שולחת בקשה חדשה; אם הגרסה תשתנה שוב, תוצג התנגשות נוספת.</p>
|
||||||
|
</section>}
|
||||||
|
{mode === 'view' ? <div className="action-buttons"><button className="secondary" disabled={!editable || busy} onClick={() => { setDraft(draftFrom(delivery)); setMode('edit'); setError(''); }}>עריכת משלוח</button><button className="danger-button" disabled={!editable || busy} onClick={() => { setDraft(draftFrom(delivery)); setMode('cancel'); setError(''); }}>ביטול משלוח</button>{delivery.syncState !== 'synced' && !server && <p className="helper">יש להשלים סנכרון לפני שינוי נוסף במשלוח.</p>}</div> : <form onSubmit={event => void save(event)}>
|
||||||
|
<h3>{mode === 'edit' ? 'עריכת פרטי משלוח' : 'ביטול משלוח'}</h3>
|
||||||
|
{mode === 'edit' ? <>
|
||||||
|
<label>לקוח מעודכן<select value={draft.customerId} required onChange={event => setDraft({ ...draft, customerId: event.target.value, addressId: '' })}>{customers.filter(c => !c.deletedAt).map(c => <option value={c.id} key={c.id}>{c.name}</option>)}</select></label>
|
||||||
|
<label>כתובת מעודכנת<select value={draft.addressId} required onChange={event => setDraft({ ...draft, addressId: event.target.value })}><option value="">בחירת כתובת</option>{addresses.filter(a => a.customerId === draft.customerId && !a.deletedAt).map(a => <option value={a.id} key={a.id}>{a.label} · {a.address}</option>)}</select></label>
|
||||||
|
<label>תאריך מתוכנן<input type="date" required value={draft.scheduledDate} onChange={event => setDraft({ ...draft, scheduledDate: event.target.value })} /></label>
|
||||||
|
<fieldset className="delivery-slot-picker">
|
||||||
|
<legend>מועד מסירה מעודכן</legend>
|
||||||
|
<div className="delivery-slot-options">{deliverySlotOptions.map(option => {
|
||||||
|
const selectedCustomer = customers.find(customer => customer.id === draft.customerId);
|
||||||
|
const window = selectedCustomer && option.value !== 'custom' ? selectedCustomer.deliveryWindows[option.value] : null;
|
||||||
|
return <label key={option.value} className={draft.deliverySlot === option.value ? 'selected' : ''}><input type="radio" name="updatedDeliverySlot" value={option.value} checked={draft.deliverySlot === option.value} onChange={() => setDraft({ ...draft, deliverySlot: option.value })} /><span><strong>{option.label}</strong><small>{option.value === 'custom' ? 'בחירת שעה מדויקת' : windowText(window)}</small></span></label>;
|
||||||
|
})}</div>
|
||||||
|
{draft.deliverySlot === 'custom' && <label className="specific-time">שעה מדויקת<input aria-label="שעה מדויקת מעודכנת" type="time" required value={draft.specificTime} onChange={event => setDraft({ ...draft, specificTime: event.target.value })} /></label>}
|
||||||
|
</fieldset>
|
||||||
|
<label>הערות מעודכנות<textarea value={draft.notes} maxLength={500} onChange={event => setDraft({ ...draft, notes: event.target.value })} /></label>
|
||||||
|
<p className="helper">סוג המשלוח, יחידות המשלוח והמחיר שנשמרו אינם משתנים בעריכה.</p>
|
||||||
|
</> : <><p>המשלוח יסומן כמבוטל. המספר והפרטים יישמרו, ולא ניתן יהיה להמשיך לערוך אותו.</p><label>סיבת הביטול<textarea name="reason" required maxLength={300} /></label></>}
|
||||||
|
<div className="action-buttons"><button className={mode === 'cancel' ? 'danger-button' : 'primary'} disabled={busy}>{busy ? 'שומר…' : mode === 'edit' ? 'שמירת שינויים' : 'אישור ביטול משלוח'}</button><button type="button" className="secondary" disabled={busy} onClick={() => setMode('view')}>חזרה ללא שמירה</button></div>
|
||||||
|
</form>}
|
||||||
|
{error && <p className="alert error" role="alert">{error}</p>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react';
|
||||||
|
import { directorySettingsSchema, type DirectorySettings } from '@model/shared';
|
||||||
|
|
||||||
|
export function DirectorySettingsForm({ request }: { request: (path: string, data?: unknown) => Promise<Record<string, unknown>> }) {
|
||||||
|
const [settings, setSettings] = useState<DirectorySettings | null>(null);
|
||||||
|
const [passwordConfigured, setPasswordConfigured] = useState(false);
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [notice, setNotice] = useState('');
|
||||||
|
function apply(body: Record<string, unknown>) {
|
||||||
|
const { passwordConfigured: configured, issuer: _, ...values } = body;
|
||||||
|
setSettings(directorySettingsSchema.parse(values)); setPasswordConfigured(configured === true);
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
void request('/local-admin/directory').then(body => { if (active) apply(body); }).catch(() => { if (active) setError('לא ניתן לטעון את הגדרות הספרייה.'); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [request]);
|
||||||
|
function field<K extends keyof DirectorySettings>(key: K, value: DirectorySettings[K]) { setSettings(current => current ? { ...current, [key]: value } : current); setNotice(''); }
|
||||||
|
async function save(event: FormEvent) {
|
||||||
|
event.preventDefault(); setBusy(true); setError(''); setNotice('');
|
||||||
|
try { apply(await request('/local-admin/directory', settings)); setNotice('הגדרות הספרייה נשמרו. השם ייקרא בכניסה הארגונית הבאה.'); }
|
||||||
|
catch (failure) { setError(failure instanceof Error ? failure.message : 'השמירה נכשלה.'); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
async function test(event: FormEvent) {
|
||||||
|
event.preventDefault(); setBusy(true); setError(''); setNotice('');
|
||||||
|
try { const result = await request('/local-admin/directory/test', { subject }); setNotice(`השם שנמצא בספרייה: ${result.displayName}`); }
|
||||||
|
catch (failure) { setError(failure instanceof Error ? failure.message : 'הבדיקה נכשלה.'); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
return <section className="directory-settings" aria-labelledby="directory-heading">
|
||||||
|
<h2 id="directory-heading">שמות מתוך Active Directory</h2>
|
||||||
|
<p className="helper">קריאת השם המלא מהספרייה לאחר כניסה ארגונית. ההתחברות נשארת דרך Synology SSO. שם אישי שבחר המשתמש מקבל עדיפות.</p>
|
||||||
|
{error && <p role="alert" className="alert error">{error}</p>}
|
||||||
|
{notice && <p role="status" className="alert">{notice}</p>}
|
||||||
|
{settings && <>
|
||||||
|
<form onSubmit={event => void save(event)}>
|
||||||
|
<fieldset disabled={busy}>
|
||||||
|
<label className="check-field"><input type="checkbox" checked={settings.enabled} onChange={event => field('enabled', event.target.checked)} />קריאת שמות מהספרייה</label>
|
||||||
|
<div className="admin-fields">
|
||||||
|
<label>כתובת הספרייה (LDAPS)<input dir="ltr" type="url" placeholder="ldaps://nas.example.internal:636" value={settings.url} maxLength={1024} required={settings.enabled} onChange={event => field('url', event.target.value)} /><span className="helper">כתובת פנימית הנגישה לשרת האפליקציה, ושמה תואם לתעודת האבטחה. בתוך קונטיינר, localhost אינו ה־NAS.</span></label>
|
||||||
|
<label>תחום המשתמש (NetBIOS)<input dir="ltr" value={settings.domain} maxLength={15} required={settings.enabled} onChange={event => field('domain', event.target.value)} /><span className="helper">לדוגמה REZNIK, כפי שמופיע לפני הלוכסן במזהה SSO.</span></label>
|
||||||
|
<label>בסיס חיפוש (Base DN)<input dir="ltr" placeholder="DC=example,DC=internal" value={settings.baseDn} maxLength={1024} required={settings.enabled} onChange={event => field('baseDn', event.target.value)} /></label>
|
||||||
|
<label>חשבון קריאה (Bind DN / UPN)<input dir="ltr" value={settings.bindDn} maxLength={1024} required={settings.enabled} autoComplete="off" onChange={event => field('bindDn', event.target.value)} /><span className="helper">חשבון ייעודי עם הרשאת קריאה לשמות המשתמשים.</span></label>
|
||||||
|
<label>סיסמת חשבון הקריאה<input dir="ltr" type="password" value={settings.bindPassword} maxLength={4096} autoComplete="new-password" onChange={event => field('bindPassword', event.target.value)} /><span className="helper">{passwordConfigured ? 'סיסמה שמורה. ריק שומר אותה אם הכתובת והחשבון לא השתנו.' : 'לא שמורה סיסמת קריאה.'}</span></label>
|
||||||
|
</div>
|
||||||
|
<label>תעודת CA פנימית (PEM, אופציונלי)<textarea dir="ltr" rows={4} maxLength={16000} value={settings.caCertificate} onChange={event => field('caCertificate', event.target.value)} /><span className="helper">תעודה ציבורית בלבד, אם השרת משתמש ברשות אישורים פנימית.</span></label>
|
||||||
|
<button className="primary" disabled={busy}>שמירת הגדרות הספרייה</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
<form onSubmit={event => void test(event)}>
|
||||||
|
<label>מזהה SSO לבדיקה<input dir="ltr" placeholder={'REZNIK\\username'} value={subject} required maxLength={255} onChange={event => setSubject(event.target.value)} /></label>
|
||||||
|
<p className="helper">הבדיקה משתמשת בהגדרות השמורות ומציגה את השם בלבד. היא אינה משנה משתמשים.</p>
|
||||||
|
<button className="secondary" disabled={busy || !subject.trim()}>בדיקת שם מהספרייה</button>
|
||||||
|
</form>
|
||||||
|
</>}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react';
|
||||||
|
import { sessionSchema, ssoSettingsSchema, type Session, type SsoSettings } from '@model/shared';
|
||||||
|
import { DirectorySettingsForm } from './DirectorySettingsForm';
|
||||||
|
import { BrandLogo } from './BrandLogo';
|
||||||
|
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
INVALID_CREDENTIALS: 'שם המשתמש או הסיסמה אינם נכונים.',
|
||||||
|
SETUP_TOKEN_REQUIRED: 'קוד ההקמה אינו תקין. הקוד נמצא בקובץ ההגדרות בשרת.',
|
||||||
|
INVALID_ACCOUNT: 'יש לבדוק את הפרטים. הסיסמה צריכה להכיל לפחות 12 תווים.',
|
||||||
|
SETUP_COMPLETE: 'כבר קיים חשבון מקומי. יש לרענן ולהתחבר אליו.',
|
||||||
|
INVALID_SETTINGS: 'יש לבדוק את כתובות SSO, מזהה הלקוח, הסוד והרשאת openid. בהפעלת הגבלת קבוצות יש להזין לפחות קבוצה מורשית אחת.',
|
||||||
|
DISCOVERY_FAILED: 'לא ניתן לאמת את הגדרות השרת. בדקו את כתובת המנפיק, כתובת הגילוי ותעודת האבטחה.',
|
||||||
|
LOCAL_ADMIN_ONLY: 'דף הניהול זמין רק דרך החיבור המקומי לשרת.',
|
||||||
|
INVALID_DIRECTORY_SETTINGS: 'נדרשים כתובת LDAPS, בסיס חיפוש, תחום, חשבון קריאה וסיסמה. בשינוי כתובת או חשבון יש להזין סיסמה מחדש. יש להגדיר תחילה את שרת SSO.',
|
||||||
|
INVALID_DIRECTORY_SUBJECT: 'יש להזין מזהה משתמש מלא מהכניסה הארגונית.',
|
||||||
|
DISABLED: 'קריאת השמות כבויה. יש להפעיל ולשמור לפני הבדיקה.',
|
||||||
|
IDENTITY_MISMATCH: 'המזהה אינו שייך לתחום שהוגדר, או שהגדרות SSO השתנו. יש לבדוק ולשמור שוב את הגדרות הספרייה.',
|
||||||
|
NOT_FOUND: 'לא נמצא משתמש תואם. בדקו את התחום, שם המשתמש ובסיס החיפוש.',
|
||||||
|
AMBIGUOUS: 'לא התקבלה התאמה יחידה. בדקו את בסיס החיפוש.',
|
||||||
|
NO_NAME: 'המשתמש נמצא, אך לא נשמר עבורו שם מלא בספרייה.',
|
||||||
|
UNAVAILABLE: 'לא ניתן לקרוא מהספרייה. בדקו חיבור פנימי לשרת, תעודת LDAPS ופרטי חשבון הקריאה.',
|
||||||
|
};
|
||||||
|
async function request(path: string, data?: unknown) {
|
||||||
|
const response = await fetch(`/api${path}`, data === undefined
|
||||||
|
? { signal: AbortSignal.timeout(15000) }
|
||||||
|
: { method: 'POST', headers: { 'content-type': 'application/json', 'x-model-request': '1' }, body: JSON.stringify(data), signal: AbortSignal.timeout(15000) });
|
||||||
|
const body = await response.json();
|
||||||
|
if (!response.ok) throw new Error(response.status === 401 ? 'נדרשת כניסה מחדש.' : response.status === 429 ? 'בוצעו ניסיונות רבים. נסו שוב בעוד דקה.' : messages[body.code] ?? 'הפעולה לא הושלמה. בדקו את החיבור והרשאות החשבון.');
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
export function LocalAdmin() {
|
||||||
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
|
const [setupRequired, setSetupRequired] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [notice, setNotice] = useState('');
|
||||||
|
const [settings, setSettings] = useState<SsoSettings | null>(null);
|
||||||
|
const [secretConfigured, setSecretConfigured] = useState(false);
|
||||||
|
const isLocalAdmin = session?.user.authProvider === 'local' && session.user.role === 'admin';
|
||||||
|
function applySettings(body: Record<string, unknown>) {
|
||||||
|
const { secretConfigured: secret, ...values } = body;
|
||||||
|
setSettings(ssoSettingsSchema.parse(values)); setSecretConfigured(secret === true);
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
void Promise.all([request('/auth/session'), request('/local-admin/status')]).then(([current, status]) => {
|
||||||
|
if (!active) return;
|
||||||
|
setSession(current.user ? sessionSchema.parse(current) : null); setSetupRequired(status.setupRequired === true);
|
||||||
|
}).catch(() => { if (active) setError('לא ניתן לטעון את דף הניהול. נדרש חיבור מקומי לשרת.'); }).finally(() => { if (active) setLoading(false); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLocalAdmin) return;
|
||||||
|
let active = true;
|
||||||
|
void request('/local-admin/settings').then(body => { if (active) applySettings(body); }).catch(failure => { if (active) setError(failure.message); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [isLocalAdmin]);
|
||||||
|
async function login(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault(); setBusy(true); setError('');
|
||||||
|
const fields = new FormData(event.currentTarget);
|
||||||
|
try {
|
||||||
|
const body = await request(`/local-admin/${setupRequired ? 'setup' : 'login'}`, {
|
||||||
|
username: fields.get('username'), password: fields.get('password'),
|
||||||
|
...(setupRequired ? { displayName: fields.get('displayName'), setupToken: fields.get('setupToken') } : {}),
|
||||||
|
});
|
||||||
|
setSession(sessionSchema.parse(body)); setSetupRequired(false);
|
||||||
|
// A local recovery login replaces any prior account's cached profile, never its outbox.
|
||||||
|
localStorage.removeItem('model-delivery-session-profile');
|
||||||
|
} catch (failure) { setError(failure instanceof Error ? failure.message : 'לא ניתן להתחבר.'); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
async function logout() {
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try { await request('/auth/logout', {}); localStorage.removeItem('model-delivery-session-profile'); setSession(null); setSettings(null); }
|
||||||
|
catch (failure) { setError(failure instanceof Error ? failure.message : 'לא ניתן לצאת.'); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
async function save(event: FormEvent) {
|
||||||
|
event.preventDefault(); setBusy(true); setError(''); setNotice('');
|
||||||
|
try { applySettings(await request('/local-admin/settings', settings)); setNotice('ההגדרות נשמרו. שינוי הגדרות מחייב את משתמשי הכניסה הארגונית להתחבר מחדש.'); }
|
||||||
|
catch (failure) { setError(failure instanceof Error ? failure.message : 'לא ניתן לשמור.'); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
async function discover() {
|
||||||
|
setBusy(true); setError(''); setNotice('');
|
||||||
|
try {
|
||||||
|
const result = await request('/local-admin/discover', {});
|
||||||
|
setNotice(`החיבור לשרת אומת.${result.pkceAdvertised ? ' השרת מפרסם תמיכה ב־PKCE.' : ' יש לוודא בשרת תמיכה ב־PKCE מסוג S256 לפני השימוש.'} הרשאות שפורסמו: ${result.scopes.join(', ') || 'לא פורסמו'}. שדה קבוצות: ${result.claims.includes('groups') ? 'פורסם' : 'לא פורסם'}.`);
|
||||||
|
} catch (failure) { setError(failure instanceof Error ? failure.message : 'לא ניתן לבדוק את החיבור.'); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
function field<K extends keyof SsoSettings>(key: K, value: SsoSettings[K]) { setSettings(current => current ? { ...current, [key]: value } : current); }
|
||||||
|
return <main className="auth-page"><section className={`auth-card ${isLocalAdmin ? 'admin-card' : ''}`}>
|
||||||
|
<div className="auth-brand"><BrandLogo className="auth-logo" /><div><h1>ניהול מקומי</h1><p>הגדרת כניסה ארגונית וחשבון גיבוי</p></div></div>
|
||||||
|
{error && <p className="alert error" role="alert">{error}</p>}
|
||||||
|
{notice && <p className="alert" role="status">{notice}</p>}
|
||||||
|
{loading ? <p role="status">טוען…</p> : isLocalAdmin ? <>
|
||||||
|
<div className="admin-toolbar"><p>שלום, {session.user.displayName}</p><a href="/">פתיחת האפליקציה</a><button className="secondary" disabled={busy} onClick={() => void logout()}>יציאה</button></div>
|
||||||
|
{settings && <form onSubmit={event => void save(event)}>
|
||||||
|
<h2>Synology SSO Server</h2>
|
||||||
|
<label className="check-field"><input type="checkbox" checked={settings.enabled} onChange={event => field('enabled', event.target.checked)} />הפעלת כניסה ארגונית</label>
|
||||||
|
<div className="admin-fields">
|
||||||
|
<label>כתובת המנפיק (Issuer)<input dir="ltr" type="url" value={settings.issuer} onChange={event => field('issuer', event.target.value)} required={settings.enabled} /></label>
|
||||||
|
<label>כתובת גילוי (Well-known URL)<input dir="ltr" type="url" value={settings.discoveryUrl} onChange={event => field('discoveryUrl', event.target.value)} /><span className="helper">ניתן להעתיק מהגדרות שירות OIDC ב־Synology. ריק = גילוי לפי המנפיק.</span></label>
|
||||||
|
<label>מזהה לקוח (Application ID)<input dir="ltr" value={settings.clientId} maxLength={255} onChange={event => field('clientId', event.target.value)} required={settings.enabled} /></label>
|
||||||
|
<label>סוד לקוח (Application Secret)<input dir="ltr" type="password" value={settings.clientSecret} maxLength={4096} autoComplete="new-password" onChange={event => field('clientSecret', event.target.value)} /><span className="helper">{secretConfigured ? 'כבר שמור סוד. השארת השדה ריק שומרת אותו.' : 'לא שמור סוד לקוח.'}</span></label>
|
||||||
|
<label>כתובת חזרה (Redirect URI)<input dir="ltr" type="url" value={settings.redirectUri} onChange={event => field('redirectUri', event.target.value)} required={settings.enabled} /><span className="helper">יש לרשום בדיוק אותה כתובת ביישום בשרת SSO.</span></label>
|
||||||
|
<label>שיטת אימות הלקוח<select dir="ltr" value={settings.authMethod} onChange={event => field('authMethod', event.target.value as SsoSettings['authMethod'])}><option value="client_secret_post">client_secret_post</option><option value="client_secret_basic">client_secret_basic</option><option value="none">none (public client)</option></select></label>
|
||||||
|
<label>הרשאות (Scopes)<input dir="ltr" value={settings.scopes} maxLength={500} onChange={event => field('scopes', event.target.value)} required={settings.enabled} /><span className="helper">הרשאת groups מתווספת אוטומטית לבקשת הכניסה.</span></label>
|
||||||
|
<label>שדה השם לברכה האישית<input dir="ltr" value={settings.displayNameClaim} maxLength={80} pattern="[a-zA-Z0-9_]+" required onChange={event => field('displayNameClaim', event.target.value)} /><span className="helper">אם השדה חסר, נשתמש בשם הזמין בפרופיל או בשם המשתמש.</span></label>
|
||||||
|
</div>
|
||||||
|
<section className="sso-access-settings" aria-labelledby="sso-access-title">
|
||||||
|
<div><p className="eyebrow">בקרת גישה</p><h3 id="sso-access-title">קבוצות מורשות</h3><p className="helper">הכניסה תאושר לחבר באחת הקבוצות הרשומות. ההתאמה מלאה ואינה רגישה להבדלי אותיות.</p></div>
|
||||||
|
<label className="check-field"><input type="checkbox" checked={settings.groupAccessEnabled} onChange={event => field('groupAccessEnabled', event.target.checked)} />הגבלת הכניסה לפי קבוצות Synology / AD</label>
|
||||||
|
<div className="admin-fields">
|
||||||
|
<label>שם שדה הקבוצות<input dir="ltr" value={settings.groupClaim} maxLength={80} pattern="[a-zA-Z0-9_]+" required onChange={event => field('groupClaim', event.target.value)} /></label>
|
||||||
|
<label>קבוצות מורשות — קבוצה בכל שורה<textarea dir="ltr" rows={4} value={settings.allowedGroups.join('\n')} required={settings.groupAccessEnabled} onChange={event => field('allowedGroups', [...new Set(event.target.value.split(/\r?\n/).map(value => value.trim()).filter(Boolean))])} placeholder="model-delivery-users" /></label>
|
||||||
|
</div>
|
||||||
|
<p className="helper">החשבון המקומי לגיבוי נשאר מורשה תמיד. לאחר הפעלת ההגבלה, משתמשי SSO יידרשו להתחבר מחדש כדי שהקבוצות ייקראו מהטוקן.</p>
|
||||||
|
</section>
|
||||||
|
<label className="check-field"><input type="checkbox" checked={settings.offlineAccess} onChange={event => field('offlineAccess', event.target.checked)} />בקשת offline_access לחידוש הכניסה</label>
|
||||||
|
<label className="check-field"><input type="checkbox" checked={settings.promptConsent} onChange={event => field('promptConsent', event.target.checked)} />בקשת הסכמה מפורשת מהמשתמש (prompt=consent)</label>
|
||||||
|
<p className="helper">יש לאפשר רק אם שרת SSO תומך בכך. עבודה ללא רשת נשמרת במכשיר גם ללא הרשאה זו.</p>
|
||||||
|
<div className="admin-toolbar"><button className="primary" disabled={busy}>{busy ? 'רגע…' : 'שמירת הגדרות'}</button><button className="secondary" type="button" disabled={busy || !settings.enabled} onClick={() => void discover()}>בדיקת ההגדרות השמורות</button></div>
|
||||||
|
</form>}
|
||||||
|
<DirectorySettingsForm request={request} />
|
||||||
|
</> : <>
|
||||||
|
{session && <p className="helper">להגדרות אלו נדרש החשבון המקומי, גם אם כבר התחברת דרך המעבדה.</p>}
|
||||||
|
<h2>{setupRequired ? 'הקמת חשבון גיבוי יחיד' : 'כניסה מקומית'}</h2>
|
||||||
|
<form onSubmit={event => void login(event)}>
|
||||||
|
{setupRequired && <><label>קוד הקמה<input name="setupToken" type="password" required autoComplete="off" dir="ltr" /><span className="helper">LOCAL_ADMIN_SETUP_TOKEN בקובץ .env של השרת.</span></label><label>השם שלך<input name="displayName" required maxLength={80} autoComplete="name" /></label></>}
|
||||||
|
<label>שם משתמש<input name="username" required minLength={3} maxLength={80} pattern="[a-zA-Z0-9._@+\-]+" autoCapitalize="none" autoComplete="username" dir="ltr" /></label>
|
||||||
|
<label>סיסמה<input name="password" type="password" required minLength={12} maxLength={128} autoComplete={setupRequired ? 'new-password' : 'current-password'} dir="ltr" /></label>
|
||||||
|
<p className="helper">לפחות 12 תווים. יש לשמור את פרטי חשבון הגיבוי במקום בטוח.</p>
|
||||||
|
<button className="primary" disabled={busy}>{busy ? 'רגע…' : setupRequired ? 'יצירת חשבון גיבוי' : 'כניסה מקומית'}</button>
|
||||||
|
</form>
|
||||||
|
<a className="auth-refresh" href="/">חזרה למסך הכניסה</a>
|
||||||
|
</>}
|
||||||
|
</section></main>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { LocalDelivery } from './db';
|
||||||
|
|
||||||
|
export function toDateKey(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateFromKey(key: string): Date {
|
||||||
|
const [year, month, day] = key.split('-').map(Number);
|
||||||
|
return new Date(year!, month! - 1, day!, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deliveryDateKey(delivery: Pick<LocalDelivery, 'scheduledDate' | 'createdAt'>): string {
|
||||||
|
return delivery.scheduledDate ?? delivery.createdAt.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays(date: Date, days: number): Date {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setDate(next.getDate() + days);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function weekStart(date: Date): Date {
|
||||||
|
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 12);
|
||||||
|
start.setDate(start.getDate() - start.getDay());
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function weekDates(anchor: Date): Date[] {
|
||||||
|
const start = weekStart(anchor);
|
||||||
|
return Array.from({ length: 7 }, (_, index) => addDays(start, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activeDeliveries(deliveries: LocalDelivery[]): LocalDelivery[] {
|
||||||
|
return deliveries.filter(delivery => !delivery.deletedAt && delivery.status !== 'cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deliveriesForDate(deliveries: LocalDelivery[], key: string): LocalDelivery[] {
|
||||||
|
return deliveries.filter(delivery => !delivery.deletedAt && deliveryDateKey(delivery) === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function valueOf(deliveries: LocalDelivery[]): number {
|
||||||
|
return activeDeliveries(deliveries).reduce((sum, delivery) => sum + delivery.totalPriceAgorot, 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import Dexie, { type Table } from 'dexie';
|
||||||
|
import { operationSchema, totalPrice, type Customer, type CustomerAddress, type Delivery, type DeliveryType, type Operation, type Change, type User } from '@model/shared';
|
||||||
|
|
||||||
|
export type SyncState = 'pending' | 'synced' | 'error';
|
||||||
|
type LocalMeta = { version: number; syncState: SyncState; syncError?: string };
|
||||||
|
export type LocalCustomer = Omit<Customer, 'version'> & LocalMeta;
|
||||||
|
export type LocalAddress = Omit<CustomerAddress, 'version'> & LocalMeta;
|
||||||
|
export type LocalDelivery = Omit<Delivery, 'version' | 'deliveryNumber'> & LocalMeta & { deliveryNumber: number | null };
|
||||||
|
export type OutboxEntry = { queueId?: number; operationId: string; operation: Operation; state: 'pending' | 'error'; error?: string; errorCode?: string; serverConflict?: Change };
|
||||||
|
export class ModelDatabase extends Dexie {
|
||||||
|
customers!: Table<LocalCustomer, string>;
|
||||||
|
addresses!: Table<LocalAddress, string>;
|
||||||
|
deliveryTypes!: Table<DeliveryType, string>;
|
||||||
|
deliveries!: Table<LocalDelivery, string>;
|
||||||
|
outbox!: Table<OutboxEntry, number>;
|
||||||
|
meta!: Table<{ key: string; value: string }, string>;
|
||||||
|
constructor(name = 'model-delivery-v1') {
|
||||||
|
super(name);
|
||||||
|
this.version(1).stores({ customers: 'id, name', addresses: 'id, customerId', deliveryTypes: 'id, code', deliveries: 'id, createdAt, customerId', outbox: '++queueId, &operationId, state', meta: 'key' });
|
||||||
|
this.version(2).stores({}).upgrade(async tx => {
|
||||||
|
await tx.table('deliveries').toCollection().modify(record => {
|
||||||
|
record.updatedAt ??= null; record.cancelledAt ??= null; record.cancellationReason ??= null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.version(3).stores({}).upgrade(async tx => {
|
||||||
|
await tx.table('customers').toCollection().modify(record => {
|
||||||
|
record.contactName ??= ''; record.phone ??= ''; record.comments ??= ''; record.updatedAt ??= null;
|
||||||
|
record.deliveryWindows ??= { morning: null, afternoon: null, lateNight: null };
|
||||||
|
});
|
||||||
|
await tx.table('addresses').toCollection().modify(record => {
|
||||||
|
record.city ??= ''; record.street ??= ''; record.buildingNumber ??= '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.version(4).stores({ deliveries: 'id, createdAt, scheduledDate, customerId' }).upgrade(async tx => {
|
||||||
|
await tx.table('deliveries').toCollection().modify(record => {
|
||||||
|
record.scheduledDate ??= typeof record.createdAt === 'string' ? record.createdAt.slice(0, 10) : null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.version(5).stores({}).upgrade(async tx => {
|
||||||
|
await tx.table('deliveries').toCollection().modify(record => {
|
||||||
|
record.deliverySlot ??= null; record.specificTime ??= null; record.deliveryWindow ??= null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export let db = new ModelDatabase();
|
||||||
|
export async function selectAccount(user: User) {
|
||||||
|
const next = new ModelDatabase(`model-delivery-user-${user.id}`);
|
||||||
|
await next.open();
|
||||||
|
// Claim the pre-authentication cache once, without deleting unsent legacy operations.
|
||||||
|
if (user.role === 'admin' && !(await next.meta.get('legacyImported'))) {
|
||||||
|
const legacy = new ModelDatabase();
|
||||||
|
try {
|
||||||
|
const owner = await legacy.transaction('rw', legacy.meta, async () => {
|
||||||
|
const existing = await legacy.meta.get('legacyOwner');
|
||||||
|
if (existing) return existing.value;
|
||||||
|
await legacy.meta.put({ key: 'legacyOwner', value: user.id });
|
||||||
|
return user.id;
|
||||||
|
});
|
||||||
|
if (owner === user.id) {
|
||||||
|
const snapshot = await legacy.transaction('r', legacy.tables, async () => Promise.all([legacy.customers.toArray(), legacy.addresses.toArray(), legacy.deliveryTypes.toArray(), legacy.deliveries.toArray(), legacy.outbox.toArray(), legacy.meta.toArray()]));
|
||||||
|
await next.transaction('rw', next.tables, async () => {
|
||||||
|
if (await next.meta.get('legacyImported')) return;
|
||||||
|
await next.customers.bulkPut(snapshot[0]); await next.addresses.bulkPut(snapshot[1]);
|
||||||
|
await next.deliveryTypes.bulkPut(snapshot[2]); await next.deliveries.bulkPut(snapshot[3]);
|
||||||
|
await next.outbox.bulkPut(snapshot[4]); await next.meta.bulkPut(snapshot[5]);
|
||||||
|
await next.meta.put({ key: 'legacyImported', value: 'true' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally { legacy.close(); }
|
||||||
|
}
|
||||||
|
await next.meta.put({ key: 'accountUserId', value: user.id });
|
||||||
|
db.close(); db = next;
|
||||||
|
}
|
||||||
|
export async function deviceId(database = db): Promise<string> {
|
||||||
|
return database.transaction('rw', database.meta, async () => {
|
||||||
|
const existing = await database.meta.get('deviceId');
|
||||||
|
if (existing) return existing.value;
|
||||||
|
const value = crypto.randomUUID();
|
||||||
|
await database.meta.add({ key: 'deviceId', value });
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLocal(kind: 'customer.create' | 'address.create' | 'delivery.create', data: unknown, database = db): Promise<string> {
|
||||||
|
const operation = operationSchema.parse({ kind, data, entityId: crypto.randomUUID(), operationId: crypto.randomUUID(), deviceId: await deviceId(database), baseVersion: 0 });
|
||||||
|
const meta = { id: operation.entityId, createdAt: new Date().toISOString(), deletedAt: null, version: 0, syncState: 'pending' as const };
|
||||||
|
await database.transaction('rw', [database.customers, database.addresses, database.deliveries, database.outbox], async () => {
|
||||||
|
if (operation.kind === 'customer.create') await database.customers.add({ ...operation.data, ...meta, updatedAt: null });
|
||||||
|
else if (operation.kind === 'address.create') {
|
||||||
|
const customer = await database.customers.get(operation.data.customerId);
|
||||||
|
if (!customer || customer.deletedAt) throw new Error('הלקוח אינו זמין');
|
||||||
|
await database.addresses.add({ ...operation.data, ...meta });
|
||||||
|
} else if (operation.kind === 'delivery.create') {
|
||||||
|
const address = await database.addresses.get(operation.data.addressId);
|
||||||
|
if (!address || address.deletedAt || address.customerId !== operation.data.customerId) throw new Error('יש לבחור כתובת השייכת ללקוח');
|
||||||
|
await database.deliveries.add({ ...operation.data, ...meta, deliveryNumber: null, totalPriceAgorot: totalPrice(operation.data.unitPriceAgorot, operation.data.shipmentUnitHundredths), status: 'created', updatedAt: null, cancelledAt: null, cancellationReason: null });
|
||||||
|
}
|
||||||
|
await database.outbox.add({ operation, operationId: operation.operationId, state: 'pending' });
|
||||||
|
});
|
||||||
|
return operation.entityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localTable(database: ModelDatabase, kind: Operation['kind']) {
|
||||||
|
return kind.startsWith('customer.') ? database.customers : kind.startsWith('address.') ? database.addresses : database.deliveries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optimisticCustomer(current: LocalCustomer, operation: Operation): LocalCustomer {
|
||||||
|
if (operation.kind === 'customer.update') return { ...current, ...operation.data, syncState: 'pending', syncError: undefined };
|
||||||
|
if (operation.kind === 'customer.remove') return { ...current, deletedAt: new Date().toISOString(), syncState: 'pending', syncError: undefined };
|
||||||
|
throw new Error('פעולה לא נתמכת');
|
||||||
|
}
|
||||||
|
export async function changeCustomer(id: string, baseVersion: number, kind: 'customer.update' | 'customer.remove', data: unknown, database = db) {
|
||||||
|
const operation = operationSchema.parse({ kind, data, entityId: id, operationId: crypto.randomUUID(), deviceId: await deviceId(database), baseVersion });
|
||||||
|
await database.transaction('rw', [database.customers, database.outbox], async () => {
|
||||||
|
const current = await database.customers.get(id);
|
||||||
|
if (!current || current.deletedAt) throw new Error('הלקוח אינו זמין');
|
||||||
|
if (current.syncState !== 'synced' || current.version === 0 || (await database.outbox.toArray()).some(entry => entry.operation.entityId === id)) throw new Error('יש להשלים סנכרון לפני שינוי נוסף בלקוח');
|
||||||
|
if (current.version !== baseVersion) throw new Error('הלקוח השתנה בזמן העריכה. פתחו אותו מחדש ובדקו את הפרטים.');
|
||||||
|
await database.customers.put(optimisticCustomer(current, operation));
|
||||||
|
await database.outbox.add({ operation, operationId: operation.operationId, state: 'pending' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveCustomerConflict(operationId: string, reviewedVersion: number, choice: 'server' | 'local', database = db) {
|
||||||
|
await database.transaction('rw', [database.customers, database.outbox], async () => {
|
||||||
|
const entry = await database.outbox.where('operationId').equals(operationId).first();
|
||||||
|
if (!entry?.serverConflict || entry.serverConflict.entityType !== 'customer') throw new Error('ההתנגשות כבר השתנתה. יש לפתוח אותה מחדש.');
|
||||||
|
const current = entry.serverConflict.record;
|
||||||
|
if (current.version !== reviewedVersion) throw new Error('התקבלה גרסת שרת חדשה. יש לבדוק אותה לפני הבחירה.');
|
||||||
|
if (choice === 'server') {
|
||||||
|
await database.customers.put({ ...current, syncState: 'synced' });
|
||||||
|
await database.outbox.delete(entry.queueId!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (current.deletedAt || !['customer.update', 'customer.remove'].includes(entry.operation.kind)) throw new Error('לא ניתן להחיל את השינוי המקומי על הגרסה הזאת');
|
||||||
|
const operation = operationSchema.parse({ ...entry.operation, operationId: crypto.randomUUID(), baseVersion: current.version });
|
||||||
|
await database.customers.put(optimisticCustomer({ ...current, syncState: 'synced' }, operation));
|
||||||
|
await database.outbox.delete(entry.queueId!);
|
||||||
|
await database.outbox.add({ operation, operationId: operation.operationId, state: 'pending' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export async function storeChange(database: ModelDatabase, change: Change) {
|
||||||
|
const table = change.entityType === 'deliveryType' ? database.deliveryTypes : change.entityType === 'customer' ? database.customers : change.entityType === 'address' ? database.addresses : database.deliveries;
|
||||||
|
const current = await table.get(change.record.id);
|
||||||
|
if (current && current.version > change.record.version) return;
|
||||||
|
if (change.entityType === 'customer') await database.customers.put({ ...change.record, syncState: 'synced' });
|
||||||
|
else if (change.entityType === 'address') await database.addresses.put({ ...change.record, syncState: 'synced' });
|
||||||
|
else if (change.entityType === 'deliveryType') await database.deliveryTypes.put(change.record);
|
||||||
|
else await database.deliveries.put({ ...change.record, syncState: 'synced' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function optimisticDelivery(current: LocalDelivery, operation: Operation): LocalDelivery {
|
||||||
|
if (operation.kind === 'delivery.update') return { ...current, ...operation.data, syncState: 'pending', syncError: undefined };
|
||||||
|
if (operation.kind === 'delivery.cancel') return { ...current, status: 'cancelled', cancellationReason: operation.data.reason, syncState: 'pending', syncError: undefined };
|
||||||
|
throw new Error('פעולה לא נתמכת');
|
||||||
|
}
|
||||||
|
export async function changeDelivery(id: string, baseVersion: number, kind: 'delivery.update' | 'delivery.cancel', data: unknown, database = db) {
|
||||||
|
const operation = operationSchema.parse({ kind, data, entityId: id, operationId: crypto.randomUUID(), deviceId: await deviceId(database), baseVersion });
|
||||||
|
await database.transaction('rw', [database.deliveries, database.addresses, database.outbox], async () => {
|
||||||
|
const current = await database.deliveries.get(id);
|
||||||
|
if (!current || current.deletedAt) throw new Error('המשלוח אינו זמין');
|
||||||
|
if (current.syncState !== 'synced' || current.version === 0 || (await database.outbox.toArray()).some(e => e.operation.entityId === id)) throw new Error('יש להשלים סנכרון לפני שינוי נוסף במשלוח');
|
||||||
|
if (current.version !== baseVersion) throw new Error('המשלוח השתנה בזמן העריכה. פתחו אותו מחדש ובדקו את הפרטים.');
|
||||||
|
if (current.status === 'cancelled') throw new Error('לא ניתן לשנות משלוח שבוטל');
|
||||||
|
if (operation.kind === 'delivery.update') {
|
||||||
|
const address = await database.addresses.get(operation.data.addressId);
|
||||||
|
if (!address || address.deletedAt || address.customerId !== operation.data.customerId) throw new Error('יש לבחור כתובת השייכת ללקוח');
|
||||||
|
}
|
||||||
|
await database.deliveries.put(optimisticDelivery(current, operation));
|
||||||
|
await database.outbox.add({ operation, operationId: operation.operationId, state: 'pending' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveConflict(operationId: string, reviewedVersion: number, choice: 'server' | 'local', database = db) {
|
||||||
|
await database.transaction('rw', [database.deliveries, database.outbox], async () => {
|
||||||
|
const entry = await database.outbox.where('operationId').equals(operationId).first();
|
||||||
|
if (!entry?.serverConflict || entry.serverConflict.entityType !== 'delivery') throw new Error('ההתנגשות כבר השתנתה. יש לפתוח אותה מחדש.');
|
||||||
|
const current = entry.serverConflict.record;
|
||||||
|
if (current.version !== reviewedVersion) throw new Error('התקבלה גרסת שרת חדשה. יש לבדוק אותה לפני הבחירה.');
|
||||||
|
if (choice === 'server') {
|
||||||
|
await database.deliveries.put({ ...current, syncState: 'synced' });
|
||||||
|
await database.outbox.delete(entry.queueId!);
|
||||||
|
} else {
|
||||||
|
if (current.status === 'cancelled' || current.deletedAt || !['delivery.update', 'delivery.cancel'].includes(entry.operation.kind)) throw new Error('לא ניתן להחיל את השינוי המקומי על הגרסה הזאת');
|
||||||
|
const operation = operationSchema.parse({ ...entry.operation, operationId: crypto.randomUUID(), baseVersion: current.version });
|
||||||
|
await database.deliveries.put(optimisticDelivery({ ...current, syncState: 'synced' }, operation));
|
||||||
|
await database.outbox.delete(entry.queueId!);
|
||||||
|
await database.outbox.add({ operation, operationId: operation.operationId, state: 'pending' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Customer, Delivery, DeliverySlot } from '@model/shared';
|
||||||
|
|
||||||
|
export const deliverySlotOptions: Array<{ value: DeliverySlot; label: string }> = [
|
||||||
|
{ value: 'morning', label: 'בוקר' },
|
||||||
|
{ value: 'afternoon', label: 'צהריים' },
|
||||||
|
{ value: 'lateNight', label: 'לילה מאוחר' },
|
||||||
|
{ value: 'custom', label: 'שעה אחרת' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type TimingCustomer = Pick<Customer, 'deliveryWindows'>;
|
||||||
|
type TimingDelivery = Pick<Delivery, 'deliverySlot' | 'specificTime' | 'deliveryWindow'>;
|
||||||
|
|
||||||
|
export function windowText(window: { start: string; end: string } | null | undefined): string {
|
||||||
|
// Isolate the numeric range so RTL layout keeps start before end.
|
||||||
|
return window ? `\u2066${window.start}–${window.end}\u2069` : 'השעות לא הוגדרו';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customerSlotWindow(customer: TimingCustomer | undefined, slot: DeliverySlot) {
|
||||||
|
if (!customer || slot === 'custom') return null;
|
||||||
|
return customer.deliveryWindows[slot];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deliveryTimingInput(customer: TimingCustomer | undefined, slot: DeliverySlot, specificTime: string) {
|
||||||
|
return slot === 'custom'
|
||||||
|
? { deliverySlot: slot, specificTime, deliveryWindow: null }
|
||||||
|
: { deliverySlot: slot, specificTime: null, deliveryWindow: customerSlotWindow(customer, slot) ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deliveryTimingText(delivery: TimingDelivery): string {
|
||||||
|
if (!delivery.deliverySlot) return 'מועד טרם נקבע';
|
||||||
|
const label = deliverySlotOptions.find(option => option.value === delivery.deliverySlot)?.label ?? 'מועד מסירה';
|
||||||
|
if (delivery.deliverySlot === 'custom') return delivery.specificTime ? `${label} · ${delivery.specificTime}` : label;
|
||||||
|
return delivery.deliveryWindow ? `${label} · ${windowText(delivery.deliveryWindow)}` : label;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { lazy, StrictMode, Suspense } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import '@fontsource/heebo/hebrew-400.css';
|
||||||
|
import '@fontsource/heebo/latin-400.css';
|
||||||
|
import '@fontsource/heebo/hebrew-600.css';
|
||||||
|
import '@fontsource/heebo/latin-600.css';
|
||||||
|
import { AuthGate } from './AuthGate';
|
||||||
|
import './styles.css';
|
||||||
|
const LocalAdmin = lazy(() => import('./LocalAdmin').then(module => ({ default: module.LocalAdmin })));
|
||||||
|
const route = window.location.pathname === '/local-admin'
|
||||||
|
? <Suspense fallback={<div className="auth-page"><div className="auth-card" role="status">טוען ניהול מקומי…</div></div>}><LocalAdmin /></Suspense>
|
||||||
|
: <AuthGate />;
|
||||||
|
createRoot(document.getElementById('root')!).render(<StrictMode>{route}</StrictMode>);
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
:root{font-family:Arial,"Segoe UI",sans-serif;color:#243b37;background:#f5f6f2;font-synthesis:none;--green:#174a43;--muted:#6e7c77;--line:#dfe5df;--white:#fff;--orange:#925516}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button,a,input,select,textarea{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.55}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #ca8b45;outline-offset:3px}button{min-height:44px}a{color:inherit}h1,h2,h3,p{margin:0}h1{font-size:clamp(27px,3vw,36px);letter-spacing:-1px;line-height:1.3}h2{font-size:19px}h3{font-size:17px}p{line-height:1.65}.app-shell{min-height:100dvh;display:grid;grid-template-columns:216px minmax(0,1fr)}.sidebar{position:sticky;top:0;height:100dvh;background:var(--green);color:#fff;padding:32px 20px;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;font-size:27px;font-weight:700;padding:0 8px}.monogram{width:42px;height:46px;display:grid;place-items:center;background:#d5e5b6;color:var(--green);border-radius:10px;font-size:28px}.brand-sub{display:block;font-size:12px;font-weight:400;margin-block-start:3px;color:#cfdfd8}nav{display:flex;flex-direction:column;gap:8px;margin-block-start:52px}nav button{border:0;border-radius:8px;background:none;color:#cfdfd8;text-align:start;padding:13px 14px;display:flex;gap:12px;align-items:center}nav button[aria-current]{background:#ffffff19;color:#fff}nav button:hover{background:#ffffff12}.sidebar-note{margin-block-start:auto;padding:24px 8px 0;border-block-start:1px solid #ffffff25;color:#c1d2ca;font-size:12px}.sidebar-note p{color:#fff;margin-block:10px}.eyebrow{font-size:11px;letter-spacing:.6px;color:var(--muted);font-weight:700}.sidebar .eyebrow{color:#d2e0b8}main{min-width:0}.topbar{min-height:76px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-block-end:1px solid var(--line);padding:16px 40px;background:#fafbf8;font-size:13px}.separator{padding-inline:12px;color:#b3bcb5}.connection{white-space:nowrap;font-size:12px;color:var(--orange)}.connection span{margin-inline-end:6px}.connection.connected{color:#327266}.content{max-width:1500px;margin:auto;padding:32px 40px}.page-heading{display:flex;justify-content:space-between;align-items:center;gap:18px;margin-block-end:24px}.page-heading h1{margin-block:8px}.page-heading p:not(.eyebrow){color:var(--muted);font-size:14px}.secondary{border:1px solid #cbd6ce;border-radius:7px;background:#fff;color:var(--green);padding:10px 17px;font-weight:600}.secondary:hover{background:#ecf2ea}.sync-button{white-space:nowrap}.sync-strip{font-size:12px;display:flex;justify-content:space-between;gap:12px;padding:13px 17px;background:#eaf0e6;border-radius:7px;color:#496152;margin-block-end:24px}.sync-strip span:last-child{color:var(--muted)}.metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:18px;margin-block-end:26px}.metrics>div{border-inline-start:3px solid #b7c9b8;background:#fff;border-radius:7px;padding:20px 22px;display:flex;justify-content:space-between;align-items:center;gap:12px}.metrics span{font-size:13px;color:#5f746b}.metrics strong{font-size:31px;font-weight:500}.workspace{display:grid;grid-template-columns:minmax(300px,1fr) minmax(330px,1.2fr);gap:24px;align-items:start}.panel{background:#fff;border:1px solid var(--line);border-radius:10px;overflow:hidden}.panel-heading{padding:22px 24px;border-block-end:1px solid #e9ede8;display:flex;justify-content:space-between;align-items:center;gap:12px}.small{font-size:11px;color:var(--muted)}form{padding:22px 24px;display:grid;gap:18px}label{display:block;font-size:13px;font-weight:600}input,select,textarea{display:block;width:100%;min-width:0;border:1px solid #d9e1d9;background:#fff;border-radius:6px;padding:12px;color:#263e35;margin-block-start:8px;min-height:46px;resize:vertical}input::placeholder,textarea::placeholder{color:#909c94;font-size:12px}select:disabled{background:#f5f6f4}.field-pair{display:grid;grid-template-columns:1fr 1fr;gap:14px}.optional{font-size:11px;color:var(--muted);font-weight:400;margin-inline-start:5px}.total{display:flex;justify-content:space-between;align-items:center;font-size:13px;padding:15px 0;border-block:1px solid #e9ede8}.total span{color:var(--muted)}.total strong{font-size:21px}.primary{width:100%;background:var(--green);color:white;border:0;border-radius:6px;padding:14px 20px;font-weight:600}.primary:hover{background:#205e53}.helper{font-size:12px;color:var(--muted);line-height:1.6}.count{background:#edf1eb;border-radius:5px;padding:5px 9px;font-size:12px}.empty{padding:65px 24px;text-align:center;color:var(--muted)}.empty h3{color:#536b60;margin-block:18px 8px}.empty p{font-size:13px}.empty-mark{font-size:32px;color:#9cae9f}.delivery-list{max-height:520px;overflow-y:auto}.delivery-row{background:#fff;border:0;border-block-end:1px solid #e9ede8;padding:21px 2Line truncated
|
||||||
|
@media(min-width:1400px){.workspace{grid-template-columns:400px minmax(0,1fr)}.content{padding-inline:56px}.topbar{padding-inline:56px}}
|
||||||
|
@media(max-width:1199px){.app-shell{grid-template-columns:172px minmax(0,1fr)}.sidebar{padding-inline:14px}.content{padding:28px 24px}.topbar{padding-inline:24px}.workspace{grid-template-columns:minmax(260px,1fr) minmax(260px,1fr);gap:16px}.panel-heading,form{padding:20px}.small{display:none}.metrics{gap:10px}.metrics>div{padding:16px 14px}}
|
||||||
|
@media(max-width:899px){.app-shell{grid-template-columns:1fr}.sidebar{position:fixed;inset-block:auto 0;inset-inline:0;height:auto;padding:0 16px max(6px,env(safe-area-inset-bottom));z-index:5;background:#fff;box-shadow:0 -1px 0 var(--line)}.brand,.sidebar-note{display:none}nav{flex-direction:row;justify-content:center;margin:0;gap:12px}nav button{color:#64786c;justify-content:center;flex:1;max-width:260px;min-height:64px;font-size:13px}nav button[aria-current]{color:var(--green);background:#eaf1e4}nav button:hover{background:#f0f3ec}.content{padding-block-end:100px}.topbar{min-height:64px}.workspace{gap:18px}.detail-grid{grid-template-columns:1fr 1fr}.detail-grid>div:last-child{grid-column:1/-1}}
|
||||||
|
@media(max-width:599px){.content{padding:24px 16px 100px}.topbar{padding:15px 16px;font-size:12px;padding-block-start:max(15px,env(safe-area-inset-top))}.page-heading{align-items:flex-start;gap:12px}.page-heading h1{font-size:28px}.sync-button{font-size:12px;padding:10px}.page-heading .eyebrow{display:none}.page-heading p:not(.eyebrow){font-size:12px}.sync-strip{flex-direction:column;gap:5px;margin-block-end:18px}.metrics{gap:8px;margin-block-end:18px}.metrics>div{flex-direction:column;align-items:flex-start;padding:14px 10px;gap:8px}.metrics span{font-size:10px}.metrics strong{font-size:26px}.workspace,.customer-workspace{grid-template-columns:1fr;gap:18px}.panel-heading,form{padding:20px}.delivery-list{max-height:none}.detail-grid{grid-template-columns:1fr;gap:20px;padding:20px}.detail-grid>div:last-child{grid-column:auto}.row-top{flex-wrap:wrap}.address-row{align-items:flex-start;flex-direction:column}.customer-list{grid-column:auto}footer{flex-direction:column;font-size:10px}.empty{padding:36px 20px}.sidebar{padding-inline:max(12px,env(safe-area-inset-right));padding-inline-end:max(12px,env(safe-area-inset-left))}}
|
||||||
|
.greeting{font-size:16px;font-weight:600;color:var(--green);margin-block-end:18px}.topbar-account{position:relative;display:flex;gap:18px;align-items:center}.account-button{border:0;background:#e2ecd9;color:var(--green);border-radius:50%;width:44px;font-weight:700;font-size:18px}.account-menu{position:absolute;inset-block-start:54px;inset-inline-end:0;z-index:8;width:260px;background:#fff;border:1px solid var(--line);border-radius:10px;box-shadow:0 12px 32px #203b3820;display:grid;gap:10px;padding:20px}.account-menu>span{font-size:12px;color:var(--muted)}.auth-page{min-height:100dvh;display:grid;place-items:center;padding:32px 18px;background:#eff3eb}.auth-card{width:min(100%,480px);border:1px solid var(--line);background:#fff;padding:32px;border-radius:14px;box-shadow:0 20px 70px #2749370a}.auth-brand{display:flex;align-items:center;gap:14px;margin-block-end:32px}.auth-brand h1{font-size:25px}.auth-brand p{font-size:12px;color:var(--muted);margin-block-start:8px}.auth-card h2{font-size:18px}.auth-card form{padding:24px 0}.auth-refresh{border:0;background:none;color:var(--green);text-decoration:underline;padding:0;margin-block-end:16px}.auth-card input[dir=ltr]{text-align:start}.delivery-actions{border-block-start:1px solid var(--line);padding:22px 24px}.delivery-actions form{padding:0;max-width:700px}.action-buttons{display:flex;align-items:center;flex-wrap:wrap;gap:12px}.action-buttons .primary{width:auto}.danger-button{background:#fff6f2;color:#963d2e;border:1px solid #deb9ad;border-radius:7px;padding:10px 18px;font-weight:600}.cancelled-badge{color:#963d2e;font-size:12px}.cancelled-notice{padding:16px;background:#fcf0eb;border-radius:7px;margin-block-end:18px;color:#893d31}.cancelled-notice span{font-size:12px}.conflict-panel{background:#fff9ef;border:1px solid #e8ceac;border-radius:9px;padding:22px;margin-block-end:20px}.conflict-panel>p{font-size:13px;margin-block:8px}.conflict-versions{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-block:18px}.conflict-versions>div{background:#fff;border:1px solid #e6ddd0;border-radius:7px;padding:16px;min-width:0}.conflict-versions h4{margin:0 0 14px;color:var(--green)}.conflict-versions p{font-size:13px;overflow-wrap:anywhere;margin-block:5px}.conflict-panel .helper{margin-block-start:15px}@media(max-width:599px){.topbar-account{gap:10px}.topbar>span{max-width:115px;font-size:11px}.topbar .separator{padding-inline:4px}.connection{font-size:10px}.auth-card{padding:25px}.conflict-versions{grid-template-columns:1fr}.conflict-panel{padding:16px}.delivery-actions{padding:20px}.action-buttons>button{width:100%}.greeting{font-size:14px}.account-menu{width:240px}}
|
||||||
|
|
||||||
|
.admin-card { width: min(100%, 900px); }
|
||||||
|
.directory-settings { border-block-start: 1px solid var(--line); padding-block-start: 24px; }
|
||||||
|
.directory-settings fieldset { min-inline-size: 0; border: 0; padding: 0; margin: 0; display: grid; gap: 16px; }
|
||||||
|
.admin-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||||
|
.admin-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-block: 16px; }
|
||||||
|
.admin-toolbar a, a.auth-refresh { display: inline-flex; align-items: center; min-height: 44px; }
|
||||||
|
.check-field { display: flex !important; align-items: center; gap: 10px; min-height: 44px; }
|
||||||
|
.check-field input { width: 22px; min-height: 22px; flex: 0 0 22px; }
|
||||||
|
@media (max-width: 600px) { .admin-fields { grid-template-columns: minmax(0, 1fr); } }
|
||||||
|
.account-menu .profile-name-form{padding:12px 0;margin-block:8px;border-block:1px solid var(--line);gap:10px}.profile-name-form label{text-align:start}
|
||||||
|
|
||||||
|
/* Liquid glass workspace */
|
||||||
|
:root{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif;--green:#0d5148;--ink:#16312e;--muted:#607573;--line:#ffffffa8;--glass:#ffffffa6;--glass-strong:#ffffffe0;--shadow:0 18px 55px #27524b18;--hairline:#315e5518;color:var(--ink);background:#eaf4ef}
|
||||||
|
body{min-height:100dvh;background:radial-gradient(circle at 12% 8%,#d9edff 0,transparent 35%),radial-gradient(circle at 90% 18%,#dff4d1 0,transparent 34%),radial-gradient(circle at 50% 96%,#f8e8d4 0,transparent 42%),linear-gradient(145deg,#edf7f2,#f4f7fb);background-attachment:fixed}
|
||||||
|
body:before,body:after{content:"";position:fixed;border-radius:50%;filter:blur(2px);z-index:-1;opacity:.7}body:before{width:320px;height:320px;inset-inline-start:18%;top:14%;background:#9ed8d246}body:after{width:420px;height:420px;inset-inline-end:8%;bottom:2%;background:#b7c8ff33}
|
||||||
|
.app-shell{grid-template-columns:238px minmax(0,1fr)}
|
||||||
|
.sidebar{background:linear-gradient(165deg,#174f49dc,#0c3c38e8);border-inline-end:1px solid #ffffff35;box-shadow:10px 0 40px #183d3720;backdrop-filter:blur(28px) saturate(150%);-webkit-backdrop-filter:blur(28px) saturate(150%);padding:28px 18px}
|
||||||
|
.monogram{border:1px solid #ffffff70;background:linear-gradient(145deg,#ffffffd9,#d8f5dc9e);box-shadow:inset 0 1px #fff,0 8px 25px #071f1b2b;border-radius:14px}
|
||||||
|
nav button{border:1px solid transparent;border-radius:14px;transition:background .18s ease,transform .18s ease,color .18s ease}nav button:hover{transform:translateY(-1px)}nav button[aria-current]{background:linear-gradient(145deg,#ffffff32,#ffffff16);border-color:#ffffff3d;box-shadow:inset 0 1px #ffffff47,0 10px 24px #001d171c}.nav-icon{font-size:20px;line-height:1}
|
||||||
|
.topbar{position:sticky;top:0;z-index:4;background:#ffffff82;border-color:#ffffffb8;box-shadow:0 7px 30px #234e4710;backdrop-filter:blur(24px) saturate(165%);-webkit-backdrop-filter:blur(24px) saturate(165%)}.topbar-path{display:flex;align-items:center;gap:12px}.hamburger{display:none;width:44px;border:1px solid #ffffffb8;background:#ffffff94;border-radius:14px;padding:11px}.hamburger span{display:block;height:2px;background:var(--green);border-radius:2px;margin:4px 0}
|
||||||
|
.content{max-width:1550px}.panel,.glass-panel,.metrics>div,.sync-strip,.account-menu,.auth-card{background:var(--glass);border:1px solid var(--line);box-shadow:var(--shadow),inset 0 1px #fff;backdrop-filter:blur(24px) saturate(145%);-webkit-backdrop-filter:blur(24px) saturate(145%)}
|
||||||
|
.panel,.glass-panel{border-radius:22px;overflow:hidden}.panel-heading{border-color:var(--hairline)}.metrics>div{border-inline-start:1px solid var(--line);border-radius:19px}.sync-strip{border-radius:16px;color:#355f59}.account-button{background:linear-gradient(145deg,#f6fff9,#cfe8dc);border:1px solid #fff;box-shadow:0 8px 18px #214a4318}.account-menu{border-radius:20px}
|
||||||
|
input,select,textarea{border-color:#305f5726;background:#ffffffa8;border-radius:13px;box-shadow:inset 0 1px 2px #173f3808;transition:border .18s ease,box-shadow .18s ease,background .18s ease}input:focus,select:focus,textarea:focus{background:#fffffff0;border-color:#4e8f83;box-shadow:0 0 0 4px #5a9b8b17}.primary,.secondary,.danger-button,.icon-button{border-radius:14px;transition:transform .16s ease,box-shadow .16s ease,background .16s ease}.primary{background:linear-gradient(145deg,#17655b,#0a493f);box-shadow:0 10px 24px #174f4930,inset 0 1px #ffffff3b}.primary:hover,.secondary:hover,.danger-button:hover,.icon-button:hover{transform:translateY(-1px)}.secondary{background:#ffffffa6;border-color:#ffffffed;box-shadow:0 7px 18px #214b4412}.count,.badge{border-radius:999px}.delivery-row{background:transparent}.delivery-row.selected{background:linear-gradient(100deg,#dcefe6ae,#ffffff76);box-shadow:inset -3px 0 #2c766b}
|
||||||
|
.clients-layout{display:grid;grid-template-columns:minmax(245px,330px) minmax(0,1fr);gap:22px;align-items:start}.clients-list{position:sticky;top:100px;max-height:calc(100dvh - 132px)}.client-list-controls{display:flex;align-items:center;justify-content:space-between;padding:10px 18px;border-block-end:1px solid var(--hairline)}.client-list-controls .check-field{font-size:12px}.client-list-scroll{max-height:calc(100dvh - 250px);overflow:auto;padding:8px}.client-row{width:100%;border:0;background:transparent;padding:12px;display:grid;grid-template-columns:44px minmax(0,1fr) 10px;align-items:center;gap:12px;text-align:start;border-radius:16px;color:var(--ink)}.client-row:hover{background:#ffffff7d}.client-row.selected{background:linear-gradient(130deg,#ffffffdf,#d8ebe2bb);box-shadow:0 10px 22px #1d4e4510,inset 0 1px #fff}.client-row>span:nth-child(2){min-width:0}.client-row strong,.client-row small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.client-row small{margin-block-start:5px;color:var(--muted);font-size:11px}.client-avatar{width:42px;height:42px;display:grid;place-items:center;border-radius:14px;background:linear-gradient(145deg,#d8eee3,#f9fff9);color:var(--green);font-weight:750;border:1px solid #fff;box-shadow:0 7px 18px #264e4714}.client-avatar.large{width:58px;height:58px;border-radius:18px;font-size:22px}.sync-dot{width:8px;height:8px;border-radius:50%;background:#d09b43}.sync-dot.synced{background:#3b9b79}.sync-dot.error{background:#c65f56}.client-detail{min-height:520px}.client-hero{display:flex;align-items:center;gap:16px;padding:24px 26px;border-block-end:1px solid var(--hairline)}.client-hero h2{font-size:25px;margin-block:4px}.client-hero p:last-child{color:var(--muted);font-size:13px}.client-actions{display:flex;gap:9px;margin-inline-start:auto}.client-summary{display:grid;grid-template-columns:1fr 1fr;gap:20px;padding:24px 26px;border-block-end:1px solid var(--hairline)}.client-summary>div{padding:15px;background:#ffffff6b;border:1px solid #fff;border-radius:16px}.client-summary .wide{grid-column:1/-1}.client-summary span{display:block;color:var(--muted);font-size:11px;margin-block-end:8px}.client-summary p{font-size:13px}.slot-chips{display:flex;flex-wrap:wrap;gap:8px}.slot-chips span{display:inline-flex;margin:0;padding:7px 10px;border-radius:999px;background:#dceee5;color:#285d54;font-size:12px}.customer-form{padding:24px 26px}.delivery-windows{border:0;margin:0;padding:0;display:grid;gap:8px}.delivery-windows legend{font-size:13px;font-weight:600;margin-block-end:8px}.window-row{display:grid;grid-template-columns:minmax(92px,1fr) 120px auto 120px;align-items:center;gap:10px;padding:9px 12px;background:#ffffff68;border:1px solid #fff;border-radius:16px}.window-row input{margin:0}.window-row .check-field{margin:0}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.form-actions,.client-actions,.action-buttons{display:flex;gap:10px;flex-wrap:wrap}.form-actions .primary,.primary.compact{width:auto}.icon-button{width:44px;border:1px solid #fff;background:#ffffffb8;color:var(--green);font-size:22px}.associated-section{padding:24px 26px;border-block-end:1px solid var(--hairline)}.associated-section:last-child{border:0}.section-title{display:flex;align-items:center;justify-content:space-between;margin-block-end:15px}.section-title span{font-size:11px;background:#ffffff91;padding:5px 9px;border-radius:999px}.inline-address-form{padding:0;display:grid;grid-template-columns:1fr 1fr 1.3fr .65fr auto;align-items:end;gap:10px;margin-block-end:16px}.inline-address-form label{min-width:0}.inline-address-form button{margin-block-end:1px}.associated-row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:13px 15px;background:#ffffff68;border:1px solid #fff;border-radius:15px;margin-block-start:8px}.associated-row strong{font-size:13px}.associated-row p{font-size:12px;color:var(--muted)}.archive-confirm{margin:20px 26px;padding:18px;border-radius:17px;background:#fff2edc9;border:1px solid #f0c8bd}.archive-confirm p{font-size:12px;margin-block:5px 13px}.sidebar-backdrop{display:none}
|
||||||
|
@media(max-width:1199px){.app-shell{grid-template-columns:205px minmax(0,1fr)}.inline-address-form{grid-template-columns:1fr 1fr}.inline-address-form button{width:100%}}
|
||||||
|
@media(max-width:899px){.app-shell{grid-template-columns:1fr}.hamburger{display:block}.sidebar{display:flex;position:fixed;z-index:30;inset-block:0;inset-inline-start:0;inset-inline-end:auto;width:min(84vw,320px);height:100dvh;padding:28px 18px;transform:translateX(110%);transition:transform .24s ease;box-shadow:-18px 0 55px #0c29264a}.sidebar.open{transform:translateX(0)}.sidebar .brand,.sidebar .sidebar-note{display:flex}.sidebar .sidebar-note{display:block}.sidebar nav{display:flex;flex-direction:column;justify-content:flex-start;margin-block-start:48px;gap:8px}.sidebar nav button{color:#d5e7e1;justify-content:flex-start;max-width:none;min-height:52px}.sidebar nav button[aria-current]{color:#fff;background:#ffffff20}.sidebar-backdrop{display:block;position:fixed;z-index:29;inset:0;border:0;background:#17342f51;backdrop-filter:blur(4px)}.content{padding-block-end:50px}.clients-list{position:static;max-height:none}.client-list-scroll{max-height:380px}.clients-layout{grid-template-columns:1fr}.topbar{padding-inline:20px}}
|
||||||
|
@media(max-width:599px){.content{padding:22px 14px 40px}.topbar{padding-inline:14px}.topbar-path>span{display:none}.connection{max-width:120px;overflow:hidden;text-overflow:ellipsis}.page-heading{align-items:center}.clients-layout{gap:14px}.client-hero{padding:19px;align-items:flex-start;flex-wrap:wrap}.client-actions{width:100%;margin:0}.client-actions button{flex:1}.client-summary{grid-template-columns:1fr;padding:18px;gap:10px}.client-summary .wide{grid-column:auto}.customer-form,.associated-section{padding:18px}.field-pair{grid-template-columns:1fr}.window-row{grid-template-columns:1fr 1fr auto 1fr;gap:7px}.window-row .check-field{grid-column:1/-1}.inline-address-form{grid-template-columns:1fr 1fr}.inline-address-form label:nth-child(3),.inline-address-form button{grid-column:1/-1}.archive-confirm{margin:14px 18px}.glass-panel,.panel{border-radius:19px}}
|
||||||
|
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}
|
||||||
|
|
||||||
|
/* Model Dental Lab identity: Heebo, dental blue, charcoal and warm ivory. */
|
||||||
|
:root{font-family:"Heebo",Arial,sans-serif;--green:#466a9c;--ink:#2d3845;--muted:#69727d;--line:#ffffffb8;--glass:#ffffffe0;--glass-strong:#fffffff2;--shadow:0 20px 60px #2d384518;--hairline:#466a9c18;color:var(--ink);background:#f8f3ed}
|
||||||
|
body{font-family:"Heebo",Arial,sans-serif;background:radial-gradient(circle at 8% 4%,#cfe3ff 0,transparent 34%),radial-gradient(circle at 96% 15%,#ffebd8 0,transparent 36%),radial-gradient(circle at 45% 105%,#dce1e8 0,transparent 43%),linear-gradient(145deg,#f9f5f0,#f4f7fb);background-attachment:fixed}
|
||||||
|
body:before{background:#6faaf72d}body:after{background:#ffbc7d26}
|
||||||
|
.sidebar{background:linear-gradient(165deg,#54595ff2,#2d3845f4);border-inline-end-color:#ffffff28;box-shadow:10px 0 45px #2d38452b}
|
||||||
|
.brand{align-items:center}.brand-word{font-size:20px;font-weight:500;letter-spacing:-.5px}.brand-sub{font-size:10px;font-weight:400;letter-spacing:.1px;color:#dce1e8}.brand-logo{display:block}.brand-logo img{display:block;width:100%;height:auto}.sidebar-logo{width:min(100%,168px);margin-inline:auto;filter:drop-shadow(0 8px 18px #10182140)}.auth-logo{display:grid;place-items:center;flex:0 0 120px;padding:12px 14px;border-radius:18px;background:linear-gradient(155deg,#4d555f,#2d3845);box-shadow:0 12px 28px #2d384529,inset 0 1px #ffffff2e}.auth-logo img{filter:drop-shadow(0 4px 9px #11182042)}
|
||||||
|
.monogram{color:#2d3845;background:linear-gradient(145deg,#ffffff,#d8e8ff);border-color:#ffffffc9;box-shadow:inset 0 1px #fff,0 10px 26px #10182135}
|
||||||
|
nav button{font-weight:500;color:#e8edf4}nav button[aria-current]{background:linear-gradient(145deg,#6faaf73a,#ffffff14);border-color:#a5cbff55;box-shadow:inset 0 1px #ffffff45,0 12px 28px #16223130}nav button:hover{background:#ffffff14}.nav-icon{color:#a9cdfd}
|
||||||
|
.sidebar-note{border-color:#ffffff28;background:#ffffff0b}.sidebar-note .eyebrow{color:#a9cdfd}.sidebar-note span{color:#cbd3dd}
|
||||||
|
.topbar{background:#fffaf2a6;border-color:#ffffffce;box-shadow:0 7px 30px #2d384512}.hamburger span{background:#466a9c}.connection.connected{color:#466a9c}
|
||||||
|
.eyebrow{color:#466a9c;letter-spacing:.08em}.greeting{color:#466a9c}.separator{color:#6faaf7}
|
||||||
|
.panel,.glass-panel,.metrics>div,.sync-strip,.account-menu,.auth-card{background:linear-gradient(145deg,#ffffffe9,#fffaf2c7);border-color:#ffffffdf;box-shadow:var(--shadow),inset 0 1px #fff;backdrop-filter:blur(26px) saturate(140%);-webkit-backdrop-filter:blur(26px) saturate(140%)}
|
||||||
|
.sync-strip{color:#536a86}.account-button{color:#466a9c;background:linear-gradient(145deg,#fff,#dbe9fb)}
|
||||||
|
input,select,textarea{border-color:#466a9c2b;background:#ffffffc2;color:#2d3845}input:focus,select:focus,textarea:focus{border-color:#6faaf7;box-shadow:0 0 0 4px #6faaf720;background:#fff}input::placeholder,textarea::placeholder{color:#8b939d}
|
||||||
|
.primary{background:linear-gradient(145deg,#78b2ff,#5a91da);box-shadow:0 11px 26px #466a9c35,inset 0 1px #ffffff70;color:#182b43}.primary:hover{background:linear-gradient(145deg,#83b9ff,#659ce4)}.secondary{color:#3f638f;background:#ffffffbd;border-color:#ffffffed}.danger-button{background:#fff6f2d9}
|
||||||
|
.delivery-row.selected{background:linear-gradient(100deg,#d8e9ffcf,#ffffff8f);box-shadow:inset -3px 0 #6faaf7}.delivery-row:hover{background:#ffffff88}.client-row.selected{background:linear-gradient(130deg,#ffffffef,#dceaffc9)}.client-avatar,.slot-chips span{color:#466a9c;background:linear-gradient(145deg,#dceaff,#fff)}.sync-dot.synced{background:#6faaf7}.count{background:#e6eef8;color:#466a9c}
|
||||||
|
.auth-page{background:radial-gradient(circle at 15% 10%,#cfe3ff,transparent 38%),radial-gradient(circle at 90% 95%,#ffebd8,transparent 42%),#f6f5f3}
|
||||||
|
|
||||||
|
.dashboard-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-block-end:22px}.summary-card{min-height:142px;padding:22px;border-radius:22px;background:linear-gradient(145deg,#fffffff0,#fff9f0cc);border:1px solid #fff;box-shadow:var(--shadow),inset 0 1px #fff;display:flex;flex-direction:column;justify-content:space-between;min-width:0}.summary-card.featured{background:linear-gradient(145deg,#7db5ffde,#d7e8ffe0);border-color:#ffffffd9}.summary-card span{font-size:13px;color:#5d6875}.summary-card strong{font-size:28px;font-weight:500;line-height:1.15;overflow-wrap:anywhere}.summary-card small{font-size:11px;color:#69727d}
|
||||||
|
.quick-actions{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-block-end:18px}.quick-action{min-height:92px;padding:15px;border:1px solid #ffffffef;border-radius:20px;background:linear-gradient(145deg,#ffffffed,#fffaf2c7);box-shadow:0 12px 32px #2d384512,inset 0 1px #fff;display:grid;grid-template-columns:44px minmax(0,1fr) auto;align-items:center;gap:12px;color:var(--ink);text-align:start;transition:transform .16s ease,box-shadow .16s ease}.quick-action:hover{transform:translateY(-2px);box-shadow:0 17px 36px #2d38451c,inset 0 1px #fff}.quick-action.primary-action{background:linear-gradient(145deg,#78b2ffe8,#dbeaffdf)}.quick-action>span:nth-child(2){min-width:0}.quick-action strong,.quick-action small{display:block}.quick-action strong{font-size:15px}.quick-action small{font-size:10px;color:var(--muted);margin-block-start:4px;line-height:1.35}.quick-action>b{font-size:19px;font-weight:400;color:#6faaf7}.quick-icon{width:44px;height:44px;display:grid;place-items:center;border-radius:15px;background:#ffffffb8;border:1px solid #fff;color:#466a9c;font-size:20px;box-shadow:0 7px 18px #466a9c16}
|
||||||
|
.week-board{overflow:hidden}.section-toolbar{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:24px 26px;border-block-end:1px solid var(--hairline)}.section-toolbar h2{font-size:23px;margin-block-start:3px}.week-controls{display:flex;align-items:center;gap:8px}.week-controls .secondary{min-height:44px}.calendar-carousel{display:grid;grid-template-columns:repeat(7,minmax(80px,1fr));gap:10px;padding:20px 24px;overflow-x:auto;scroll-snap-type:x proximity}.day-card{min-width:80px;min-height:142px;padding:14px 10px;border:1px solid #ffffffdf;background:#ffffff80;border-radius:18px;color:#2d3845;display:flex;flex-direction:column;align-items:center;gap:4px;scroll-snap-align:start;transition:transform .16s ease,background .16s ease,box-shadow .16s ease}.day-card:hover{transform:translateY(-2px);background:#fff}.day-card[aria-pressed=true]{background:linear-gradient(155deg,#6faaf7,#a9ccfa);box-shadow:0 14px 30px #466a9c2f;color:#20334b}.day-card.today{border-color:#6faaf7}.day-card span{font-size:12px}.day-card strong{font-size:27px;font-weight:500}.day-card b{font-size:19px;margin-block-start:auto}.day-card small{font-size:10px}
|
||||||
|
.day-summary{padding:2px 24px 24px}.day-summary-heading{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:18px 2px}.day-summary-heading span,.day-summary-heading strong{display:block}.day-summary-heading span{font-size:12px;color:var(--muted)}.day-summary-heading strong{font-size:18px;margin-block-start:4px}.dashboard-delivery-list{display:grid;gap:8px}.dashboard-delivery-list article{display:grid;grid-template-columns:64px minmax(0,1fr) auto;align-items:center;gap:14px;padding:14px 16px;border-radius:16px;background:#ffffff91;border:1px solid #fff}.dashboard-delivery-list article.cancelled{opacity:.55}.dashboard-delivery-list p{font-size:11px;color:var(--muted);margin-block-start:2px}.delivery-number{font-size:12px;color:#466a9c;font-weight:600}.empty-day{padding:30px;text-align:center;color:var(--muted);background:#ffffff64;border-radius:16px}
|
||||||
|
.date-filter{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 18px;margin-block-end:18px;border-radius:16px;background:#dbeaffc7;border:1px solid #fff;font-size:13px}.date-filter .secondary{flex:0 0 auto}
|
||||||
|
.costs-panel{overflow:hidden}.costs-explainer{margin:18px 24px 0;padding:14px 16px;border-radius:14px;background:#fff3e7;color:#59636f;font-size:12px}.cost-table{padding:16px 24px 24px}.cost-row{display:grid;grid-template-columns:minmax(0,1fr) 120px 150px;align-items:center;gap:16px;min-height:58px;padding:10px 14px;border-block-end:1px solid var(--hairline)}.cost-row:last-child{border:0}.cost-row.header{min-height:42px;color:var(--muted);font-size:11px}.cost-row>span:last-child,.cost-row>strong:last-child{text-align:end}
|
||||||
|
@media(max-width:1199px){.quick-actions,.dashboard-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.calendar-carousel{grid-template-columns:repeat(7,minmax(92px,1fr))}}
|
||||||
|
@media(max-width:899px){.sidebar nav button{color:#e8edf4}.sidebar nav button[aria-current]{color:#fff;background:#6faaf72f}.sidebar-backdrop{background:#26333f62}.dashboard-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||||
|
@media(max-width:599px){.auth-brand{align-items:flex-start}.auth-logo{flex-basis:104px;padding:10px 12px}.quick-actions{grid-template-columns:1fr;gap:9px}.quick-action{min-height:78px;padding:12px}.dashboard-metrics{gap:10px}.summary-card{min-height:126px;padding:16px;border-radius:18px}.summary-card strong{font-size:23px}.section-toolbar{padding:18px;align-items:flex-start}.section-toolbar h2{font-size:19px}.week-controls .secondary{display:none}.calendar-carousel{display:flex;padding:16px 18px;gap:8px}.day-card{flex:0 0 82px;min-height:130px}.day-summary{padding:0 18px 18px}.day-summary-heading{align-items:flex-start;flex-direction:column}.day-summary-heading .primary{width:100%}.dashboard-delivery-list article{grid-template-columns:52px minmax(0,1fr);gap:10px}.dashboard-delivery-list article>span:last-child{grid-column:2}.date-filter{align-items:flex-start;flex-direction:column}.date-filter .secondary{width:100%}.costs-metrics{grid-template-columns:1fr 1fr}.cost-row{grid-template-columns:minmax(0,1fr) 56px 100px;padding-inline:6px;gap:7px}.cost-table{padding-inline:14px}.costs-explainer{margin-inline:14px}.cost-row strong{font-size:12px}}
|
||||||
|
|
||||||
|
.delivery-slot-picker{border:0;margin:0;padding:0;min-inline-size:0}.delivery-slot-picker legend{font-size:13px;font-weight:600;margin-block-end:9px}.delivery-slot-options{display:grid;grid-template-columns:1fr 1fr;gap:9px}.delivery-slot-options label{min-height:64px;display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid #466a9c24;border-radius:15px;background:#ffffff96;cursor:pointer;transition:border .16s ease,background .16s ease,box-shadow .16s ease}.delivery-slot-options label.selected{border-color:#6faaf7;background:linear-gradient(145deg,#e5f0ff,#fff);box-shadow:0 7px 18px #466a9c18}.delivery-slot-options input{width:19px;min-width:19px;min-height:19px;height:19px;margin:0;padding:0;flex:0 0 19px;accent-color:#5a91da}.delivery-slot-options span,.delivery-slot-options strong,.delivery-slot-options small{display:block}.delivery-slot-options strong{font-size:13px}.delivery-slot-options small{font-size:10px;color:var(--muted);font-weight:400;margin-block-start:3px}.specific-time{margin-block-start:12px;max-width:220px}.delivery-timing-inline{font-size:12px;color:#466a9c;font-weight:600}.detail-grid>div:last-child p:first-of-type{color:#466a9c;font-weight:600}
|
||||||
|
@media(max-width:599px){.delivery-slot-options{grid-template-columns:1fr}.delivery-slot-options label{min-height:58px}.specific-time{max-width:none}}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { changeSchema, pushResponseSchema, pullResponseSchema } from '@model/shared';
|
||||||
|
import { db, localTable, storeChange, type ModelDatabase } from './db';
|
||||||
|
|
||||||
|
const errorText: Record<string, string> = {
|
||||||
|
VERSION_CONFLICT: 'קיימת גרסה אחרת בשרת. הרשומה המקומית נשמרה ונדרשת בדיקה.',
|
||||||
|
OPERATION_REUSED: 'פעולת הסנכרון אינה תואמת לשרת. נדרשת בדיקה.',
|
||||||
|
CATALOG_CONFLICT: 'הגדרות סוג המשלוח השתנו. הטיוטה נשמרה ונדרשת בדיקה.',
|
||||||
|
ADDRESS_MISMATCH: 'הכתובת אינה זמינה או אינה שייכת ללקוח.',
|
||||||
|
CUSTOMER_MISSING: 'הלקוח עדיין אינו זמין בשרת.',
|
||||||
|
CUSTOMER_REMOVED: 'הלקוח הועבר לארכיון בשרת. יש לבדוק את הגרסאות.',
|
||||||
|
DELIVERY_CANCELLED: 'המשלוח בוטל בשרת. יש לבדוק את הגרסאות.',
|
||||||
|
DELIVERY_MISSING: 'המשלוח אינו זמין בשרת.',
|
||||||
|
};
|
||||||
|
export type Fetcher = (input: string, init?: RequestInit) => Promise<Response>;
|
||||||
|
export class ConnectionError extends Error {}
|
||||||
|
export class AuthenticationError extends Error {
|
||||||
|
constructor() { super('יש להתחבר מחדש. כל השינויים נשמרו במכשיר.'); }
|
||||||
|
}
|
||||||
|
export async function runSync(database: ModelDatabase = db, fetcher: Fetcher = fetch): Promise<void> {
|
||||||
|
const tables = [database.customers, database.addresses, database.deliveries, database.deliveryTypes, database.outbox, database.meta];
|
||||||
|
const accountId = (await database.meta.get('accountUserId'))?.value;
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json', 'x-model-request': '1' };
|
||||||
|
if (accountId) headers['x-model-user'] = accountId;
|
||||||
|
// FIFO is significant: an offline customer must reach the server before its addresses and deliveries.
|
||||||
|
for (const entry of await database.outbox.orderBy('queueId').toArray()) {
|
||||||
|
if (entry.state === 'error') continue;
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetcher('/api/sync/push', { method: 'POST', headers, body: JSON.stringify(entry.operation), signal: AbortSignal.timeout(10000) });
|
||||||
|
} catch { throw new ConnectionError('אין חיבור לשרת. השינויים שמורים במכשיר ויישלחו בחיבור הבא.'); }
|
||||||
|
if (response.status >= 500 || response.status === 429) throw new ConnectionError('השרת אינו זמין. השינויים שמורים ויתבצע ניסיון נוסף.');
|
||||||
|
if (response.status === 401) throw new AuthenticationError();
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json() as { code?: string; current?: unknown };
|
||||||
|
const parsedConflict = changeSchema.safeParse(body.current);
|
||||||
|
const serverConflict = parsedConflict.success && parsedConflict.data.record.id === entry.operation.entityId ? parsedConflict.data : undefined;
|
||||||
|
const error = errorText[body.code ?? ''] ?? 'לא ניתן לסנכרן את הרשומה. הנתונים שמורים ונדרשת בדיקה.';
|
||||||
|
await database.transaction('rw', tables, async () => {
|
||||||
|
await database.outbox.update(entry.queueId!, { state: 'error', error, errorCode: body.code, serverConflict });
|
||||||
|
await localTable(database, entry.operation.kind).update(entry.operation.entityId, { syncState: 'error', syncError: error });
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { change } = pushResponseSchema.parse(await response.json());
|
||||||
|
if (change.record.id !== entry.operation.entityId || change.entityType !== entry.operation.kind.split('.')[0]) throw new Error('תגובת הסנכרון אינה תקינה');
|
||||||
|
await database.transaction('rw', tables, async () => {
|
||||||
|
await storeChange(database, change);
|
||||||
|
await database.outbox.delete(entry.queueId!);
|
||||||
|
});
|
||||||
|
// A push receipt does NOT advance the pull cursor: unseen changes may precede this write.
|
||||||
|
}
|
||||||
|
let hasMore = true;
|
||||||
|
while (hasMore) {
|
||||||
|
const cursor = (await database.meta.get('cursor'))?.value ?? '0';
|
||||||
|
let response: Response;
|
||||||
|
try { response = await fetcher(`/api/sync/pull?cursor=${cursor}`, { headers, signal: AbortSignal.timeout(10000) }); }
|
||||||
|
catch { throw new ConnectionError('אין חיבור לשרת. השינויים שמורים במכשיר ויישלחו בחיבור הבא.'); }
|
||||||
|
if (response.status >= 500 || response.status === 429) throw new ConnectionError('השרת אינו זמין. השינויים שמורים ויתבצע ניסיון נוסף.');
|
||||||
|
if (response.status === 401) throw new AuthenticationError();
|
||||||
|
if (!response.ok) throw new Error(response.status === 409 ? 'היסטוריית השרת השתנתה. הנתונים המקומיים שמורים ונדרשת בדיקה.' : 'לא ניתן לקבל עדכונים מהשרת כעת.');
|
||||||
|
const page = pullResponseSchema.parse(await response.json());
|
||||||
|
if (BigInt(page.nextCursor) < BigInt(cursor) || (page.hasMore && page.nextCursor === cursor)) throw new Error('סמן הסנכרון אינו תקין');
|
||||||
|
await database.transaction('rw', tables, async () => {
|
||||||
|
const appliedCursor = (await database.meta.get('cursor'))?.value ?? '0';
|
||||||
|
if (BigInt(appliedCursor) > BigInt(cursor)) return;
|
||||||
|
const entries = await database.outbox.toArray();
|
||||||
|
const pending = new Set(entries.map(e => e.operation.entityId));
|
||||||
|
for (const { change } of page.changes) {
|
||||||
|
// Preserve the local draft on conflicts; the server copy is retained in the outbox error.
|
||||||
|
if (!pending.has(change.record.id)) await storeChange(database, change);
|
||||||
|
else for (const entry of entries.filter(e => e.operation.entityId === change.record.id && e.state === 'error' && e.serverConflict)) {
|
||||||
|
if (change.record.version > entry.serverConflict!.record.version) await database.outbox.update(entry.queueId!, { serverConflict: change });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await database.meta.put({ key: 'cursor', value: page.nextCursor });
|
||||||
|
await database.meta.put({ key: 'lastSyncedAt', value: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
hasMore = page.hasMore;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSyncCoordinator(work: () => Promise<void>) {
|
||||||
|
let inFlight: Promise<void> | undefined;
|
||||||
|
let syncRequested = false;
|
||||||
|
return function requestSync(): Promise<void> {
|
||||||
|
syncRequested = true;
|
||||||
|
if (inFlight) return inFlight;
|
||||||
|
const task = (async () => {
|
||||||
|
do {
|
||||||
|
syncRequested = false;
|
||||||
|
try { await work(); }
|
||||||
|
catch (error) {
|
||||||
|
// A reconnect/local write can arrive while an earlier offline request is failing.
|
||||||
|
// Honor that queued trigger before rejecting the shared promise.
|
||||||
|
if (!syncRequested) throw error;
|
||||||
|
}
|
||||||
|
} while (syncRequested);
|
||||||
|
})().finally(() => { inFlight = undefined; });
|
||||||
|
inFlight = task;
|
||||||
|
return task;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export const synchronize = createSyncCoordinator(async () => {
|
||||||
|
try {
|
||||||
|
const current = db;
|
||||||
|
if (navigator.locks) await navigator.locks.request('model-delivery-sync', () => runSync(current));
|
||||||
|
else await runSync(current);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthenticationError) window.dispatchEvent(new Event('model-session-expired'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export async function retryErrors() {
|
||||||
|
await db.transaction('rw', [db.outbox, db.customers, db.addresses, db.deliveries], async () => {
|
||||||
|
for (const entry of await db.outbox.where('state').equals('error').toArray()) {
|
||||||
|
if (entry.serverConflict) continue;
|
||||||
|
await db.outbox.update(entry.queueId!, { state: 'pending', error: undefined });
|
||||||
|
await localTable(db, entry.operation.kind).update(entry.operation.entityId, { syncState: 'pending', syncError: undefined });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { testAccount } from '../helpers/account';
|
||||||
|
|
||||||
|
test('local recovery settings, masked secret, SSO greeting and offline re-entry', async ({ page, context }) => {
|
||||||
|
const request = context.request;
|
||||||
|
const state = await (await request.get('/api/local-admin/status')).json();
|
||||||
|
if (state.setupRequired) {
|
||||||
|
expect((await request.post('/api/local-admin/setup', { headers: { 'x-model-request': '1' }, data: { ...testAccount, setupToken: 'isolated-test-bootstrap' } })).ok()).toBe(true);
|
||||||
|
await request.post('/api/auth/logout', { headers: { 'x-model-request': '1' } });
|
||||||
|
}
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('button', { name: 'כניסה עם חשבון המעבדה' })).toBeVisible();
|
||||||
|
await expect(page.locator('a[href="/local-admin"]')).toHaveCount(0);
|
||||||
|
await page.goto('/local-admin');
|
||||||
|
await page.getByLabel('שם משתמש', { exact: true }).fill(testAccount.username);
|
||||||
|
await page.getByLabel('סיסמה', { exact: true }).fill(testAccount.password);
|
||||||
|
await page.getByRole('button', { name: 'כניסה מקומית', exact: true }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Synology SSO Server' })).toBeVisible();
|
||||||
|
await page.getByLabel('כתובת המנפיק (Issuer)', { exact: true }).fill('http://localhost:4005');
|
||||||
|
await page.getByLabel('כתובת גילוי (Well-known URL)').fill('http://localhost:4005/.well-known/openid-configuration');
|
||||||
|
await page.getByLabel('מזהה לקוח (Application ID)', { exact: true }).fill('model-test');
|
||||||
|
await page.getByLabel('סוד לקוח (Application Secret)').fill('mock-test-secret');
|
||||||
|
await page.getByLabel('כתובת חזרה (Redirect URI)').fill('http://localhost:4174/api/auth/callback');
|
||||||
|
await page.getByLabel('הרשאות (Scopes)').fill('openid profile email groups');
|
||||||
|
await page.getByLabel('הגבלת הכניסה לפי קבוצות Synology / AD').check();
|
||||||
|
await page.getByLabel('שם שדה הקבוצות').fill('groups');
|
||||||
|
await page.getByLabel('קבוצות מורשות — קבוצה בכל שורה').fill('model-delivery-users');
|
||||||
|
await page.getByLabel('הפעלת כניסה ארגונית').check();
|
||||||
|
await page.getByLabel('בקשת offline_access לחידוש הכניסה').check();
|
||||||
|
await page.getByRole('button', { name: 'שמירת הגדרות', exact: true }).click();
|
||||||
|
await expect(page.getByRole('status')).toContainText('ההגדרות נשמרו');
|
||||||
|
await expect(page.getByLabel('סוד לקוח (Application Secret)')).toHaveValue('');
|
||||||
|
await page.getByRole('button', { name: 'בדיקת ההגדרות השמורות' }).click();
|
||||||
|
await expect(page.getByRole('status')).toContainText('החיבור לשרת אומת');
|
||||||
|
const directory = page.locator('.directory-settings');
|
||||||
|
await expect(directory.getByRole('heading', { name: 'שמות מתוך Active Directory' })).toBeVisible();
|
||||||
|
await directory.getByLabel('קריאת שמות מהספרייה', { exact: true }).uncheck();
|
||||||
|
await directory.getByLabel('כתובת הספרייה (LDAPS)').fill('ldaps://directory.example.invalid:636');
|
||||||
|
await directory.getByLabel('חשבון קריאה (Bind DN / UPN)').fill('reader@example.invalid');
|
||||||
|
await directory.getByLabel('סיסמת חשבון הקריאה').fill('browser-test-directory-secret');
|
||||||
|
await directory.getByRole('button', { name: 'שמירת הגדרות הספרייה' }).click();
|
||||||
|
await expect(directory.getByRole('status')).toContainText('הגדרות הספרייה נשמרו');
|
||||||
|
await expect(directory.getByLabel('סיסמת חשבון הקריאה')).toHaveValue('');
|
||||||
|
await directory.getByLabel('מזהה SSO לבדיקה').fill('REZNIK\\test.sso');
|
||||||
|
await directory.getByRole('button', { name: 'בדיקת שם מהספרייה' }).click();
|
||||||
|
await expect(directory.getByRole('alert')).toContainText('קריאת השמות כבויה');
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||||
|
await page.screenshot({ path: 'test-results/model-delivery-local-admin.png', fullPage: true });
|
||||||
|
await page.getByRole('link', { name: 'פתיחת האפליקציה' }).click();
|
||||||
|
await expect(page.locator('.greeting')).toHaveText(`שלום, ${testAccount.displayName}`);
|
||||||
|
await page.getByRole('button', { name: 'החשבון שלי' }).click();
|
||||||
|
await page.getByRole('button', { name: 'יציאה מהחשבון' }).click();
|
||||||
|
await page.getByRole('button', { name: 'כניסה עם חשבון המעבדה' }).click();
|
||||||
|
await expect(page.locator('.greeting')).toHaveText('שלום, טום בדיקה');
|
||||||
|
await page.getByRole('button', { name: 'החשבון שלי' }).click();
|
||||||
|
await page.getByLabel('שם לתצוגה', { exact: true }).fill('תום רוף בדיקה');
|
||||||
|
await page.getByRole('button', { name: 'שמירת השם', exact: true }).click();
|
||||||
|
await expect(page.locator('.greeting')).toHaveText('שלום, תום רוף בדיקה');
|
||||||
|
const session = await (await request.get('/api/auth/session')).json();
|
||||||
|
expect(session.user.authProvider).toBe('oidc');
|
||||||
|
expect(session.user.groups).toEqual(['model-delivery-users']);
|
||||||
|
expect(Object.keys(session).sort()).toEqual(['expiresAt', 'user']);
|
||||||
|
expect(await page.evaluate(() => Object.keys(localStorage).some(key => /token|secret/i.test(key)))).toBe(false);
|
||||||
|
await page.evaluate(async () => { await navigator.serviceWorker.ready; });
|
||||||
|
await page.reload();
|
||||||
|
await expect.poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller))).toBe(true);
|
||||||
|
await context.setOffline(true);
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('.greeting')).toHaveText('שלום, תום רוף בדיקה');
|
||||||
|
await context.setOffline(false);
|
||||||
|
await page.getByRole('button', { name: 'החשבון שלי' }).click();
|
||||||
|
await page.getByRole('button', { name: 'שימוש בשם הארגוני' }).click();
|
||||||
|
await expect(page.locator('.greeting')).toHaveText('שלום, טום בדיקה');
|
||||||
|
// Restore this reusable test user's preference after checking offline persistence.
|
||||||
|
expect((await request.post('/api/auth/profile', { headers: { 'x-model-request': '1', 'x-model-user': session.user.id }, data: { displayName: 'טום בדיקה' } })).ok()).toBe(true);
|
||||||
|
await page.goto('/local-admin');
|
||||||
|
await expect(page.getByRole('button', { name: 'כניסה מקומית', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByLabel('סוד לקוח (Application Secret)')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.directory-settings')).toHaveCount(0);
|
||||||
|
// Only the isolated test database is configured by this test. Disable its test provider afterwards.
|
||||||
|
const local = await request.post('/api/local-admin/login', { headers: { 'x-model-request': '1' }, data: { username: testAccount.username, password: testAccount.password } });
|
||||||
|
expect(local.ok()).toBe(true);
|
||||||
|
const { secretConfigured: _, ...settings } = await (await request.get('/api/local-admin/settings')).json();
|
||||||
|
expect((await request.post('/api/local-admin/settings', { headers: { 'x-model-request': '1' }, data: { ...settings, enabled: false } })).ok()).toBe(true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
import { testAccount } from '../helpers/account';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
const request = context.request;
|
||||||
|
const state = await (await request.get('/api/local-admin/status')).json();
|
||||||
|
const result = await request.post(`/api/local-admin/${state.setupRequired ? 'setup' : 'login'}`, { headers: { 'x-model-request': '1' }, data: state.setupRequired ? { ...testAccount, setupToken: 'isolated-test-bootstrap' } : { username: testAccount.username, password: testAccount.password } });
|
||||||
|
expect(result.ok()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deliveryForm(page: Page, notes: string) {
|
||||||
|
await page.getByRole('combobox', { name: 'לקוח', exact: true }).selectOption({ label: 'מרפאת הדגמה' });
|
||||||
|
await page.getByRole('combobox', { name: 'כתובת למשלוח', exact: true }).selectOption('20000000-0000-4000-8000-000000000002');
|
||||||
|
await page.getByRole('combobox', { name: 'סוג משלוח', exact: true }).selectOption('10000000-0000-4000-8000-000000000003');
|
||||||
|
await page.getByLabel('מחיר ליחידה (₪)').fill('10.01');
|
||||||
|
await page.getByRole('textbox', { name: /הערות למשלוח/ }).fill(notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDeliveryView(page: Page) {
|
||||||
|
const heading = page.getByRole('heading', { name: 'המשלוחים שלך' });
|
||||||
|
if (!await heading.isVisible()) await page.getByRole('button', { name: 'משלוחים', exact: true }).click();
|
||||||
|
await expect(heading).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('online creation then offline save, offline reload, reconnect and server verification', async ({ page, context }) => {
|
||||||
|
const request = context.request;
|
||||||
|
const browserErrors: string[] = [];
|
||||||
|
page.on('pageerror', error => browserErrors.push(error.message));
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('heading', { name: 'תמונת המצב שלך' })).toBeVisible();
|
||||||
|
await expect(page.locator('.day-card')).toHaveCount(7);
|
||||||
|
await expect(page.getByRole('region', { name: 'פעולות מהירות' }).getByRole('button')).toHaveCount(4);
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||||
|
await expect.poll(() => page.locator('.sidebar').evaluate(element => { const rect = element.getBoundingClientRect(); return rect.left >= innerWidth || rect.right <= 0; })).toBe(true);
|
||||||
|
await page.screenshot({ path: 'test-results/model-delivery-dashboard-mobile.png' });
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await page.getByRole('button', { name: /משלוח חדש/ }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'המשלוחים שלך' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('combobox', { name: 'לקוח', exact: true }).locator('option')).toContainText(['בחירת לקוח', 'מרפאת הדגמה']);
|
||||||
|
await expect(page.getByLabel('תאריך מתוכנן')).toHaveValue(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
await page.evaluate(async () => { await navigator.serviceWorker.ready; });
|
||||||
|
await page.reload();
|
||||||
|
await openDeliveryView(page);
|
||||||
|
await expect.poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller))).toBe(true);
|
||||||
|
|
||||||
|
const onlineNotes = `Online browser test ${Date.now()}`;
|
||||||
|
await deliveryForm(page, onlineNotes);
|
||||||
|
await page.getByRole('radio', { name: /שעה אחרת/ }).check();
|
||||||
|
await page.getByLabel('שעה מדויקת', { exact: true }).fill('14:35');
|
||||||
|
await page.getByRole('button', { name: '+ שמירת משלוח', exact: true }).click();
|
||||||
|
await expect(page.locator('.detail-panel')).toContainText(onlineNotes);
|
||||||
|
await expect(page.locator('.detail-panel')).toContainText('שעה אחרת · 14:35');
|
||||||
|
await expect(page.locator('.delivery-row[aria-pressed="true"]')).toContainText('מסונכרן');
|
||||||
|
await expect(page.locator('.delivery-row[aria-pressed="true"]')).toContainText('#');
|
||||||
|
|
||||||
|
await context.setOffline(true);
|
||||||
|
await expect(page.locator('.connection')).toContainText('ללא חיבור לרשת');
|
||||||
|
const offlineNotes = `Offline browser test ${Date.now()}`;
|
||||||
|
await deliveryForm(page, offlineNotes);
|
||||||
|
await page.getByRole('radio', { name: /צהריים/ }).check();
|
||||||
|
await page.getByRole('button', { name: '+ שמירת משלוח', exact: true }).click();
|
||||||
|
await expect(page.locator('.detail-panel')).toContainText(offlineNotes);
|
||||||
|
const offlineRow = page.locator('.delivery-row[aria-pressed="true"]');
|
||||||
|
await expect(offlineRow).toContainText('ממתין לסנכרון');
|
||||||
|
await expect(offlineRow).toContainText('ממתין למספר');
|
||||||
|
const rowId = await offlineRow.getAttribute('data-testid');
|
||||||
|
expect(rowId).toBeTruthy();
|
||||||
|
await page.reload();
|
||||||
|
await openDeliveryView(page);
|
||||||
|
await expect(page.getByTestId(rowId!)).toContainText('ממתין לסנכרון');
|
||||||
|
await expect(page.getByTestId(rowId!)).toContainText('ממתין למספר');
|
||||||
|
|
||||||
|
await context.setOffline(false);
|
||||||
|
try { await expect(page.getByTestId(rowId!)).toContainText('מסונכרן'); }
|
||||||
|
catch (error) {
|
||||||
|
console.log(await page.evaluate(async () => ({ online: navigator.onLine, connection: document.querySelector('.connection')?.textContent, sync: document.querySelector('.sync-button')?.textContent, alerts: Array.from(document.querySelectorAll('.alert')).map(e => e.textContent), locks: await navigator.locks.query() })));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await expect(page.getByTestId(rowId!)).toContainText('#');
|
||||||
|
await page.reload();
|
||||||
|
await openDeliveryView(page);
|
||||||
|
await expect(page.getByTestId(rowId!)).toContainText('מסונכרן');
|
||||||
|
const allChanges: Array<{ change: { entityType: string; record: { id: string; notes?: string; deliveryNumber?: number; deliverySlot?: string; specificTime?: string | null } } }> = [];
|
||||||
|
let cursor = '0';
|
||||||
|
const session = await (await request.get('/api/auth/session')).json();
|
||||||
|
while (true) {
|
||||||
|
const response = await request.get(`/api/sync/pull?cursor=${cursor}&limit=200`, { headers: { 'x-model-user': session.user.id } });
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
const result = await response.json(); allChanges.push(...result.changes); cursor = result.nextCursor;
|
||||||
|
if (!result.hasMore) break;
|
||||||
|
}
|
||||||
|
const saved = allChanges.filter(c => c.change.entityType === 'delivery' && c.change.record.notes === offlineNotes);
|
||||||
|
expect(saved).toHaveLength(1);
|
||||||
|
expect(`delivery-${saved[0]!.change.record.id}`).toBe(rowId);
|
||||||
|
expect(saved[0]!.change.record.deliveryNumber).toBeGreaterThan(0);
|
||||||
|
expect(saved[0]!.change.record.deliverySlot).toBe('afternoon');
|
||||||
|
const onlineSaved = allChanges.filter(c => c.change.record.notes === onlineNotes);
|
||||||
|
expect(onlineSaved).toHaveLength(1);
|
||||||
|
expect(onlineSaved[0]!.change.record).toMatchObject({ deliverySlot: 'custom', specificTime: '14:35' });
|
||||||
|
|
||||||
|
for (const viewport of [{ width: 390, height: 844 }, { width: 768, height: 1024 }, { width: 1024, height: 768 }, { width: 1440, height: 900 }]) {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await expect(page.getByRole('button', { name: '+ שמירת משלוח' })).toBeVisible();
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||||
|
}
|
||||||
|
await page.screenshot({ path: 'test-results/model-delivery-desktop.png', fullPage: true });
|
||||||
|
await page.getByRole('button', { name: 'לוח בקרה', exact: true }).click();
|
||||||
|
await expect(page.getByRole('region', { name: 'לוח משלוחים שבועי' })).toBeVisible();
|
||||||
|
await page.screenshot({ path: 'test-results/model-delivery-dashboard.png' });
|
||||||
|
await page.getByRole('button', { name: 'עלויות', exact: true }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'עלויות משלוחים' })).toBeVisible();
|
||||||
|
await page.screenshot({ path: 'test-results/model-delivery-costs.png' });
|
||||||
|
await openDeliveryView(page);
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await expect(page.getByRole('button', { name: 'פתיחת תפריט' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'פתיחת תפריט' }).click();
|
||||||
|
await expect(page.locator('.sidebar')).toHaveClass(/open/);
|
||||||
|
await page.getByRole('button', { name: 'משלוחים', exact: true }).click();
|
||||||
|
await expect(page.locator('.sidebar')).not.toHaveClass(/open/);
|
||||||
|
await page.screenshot({ path: 'test-results/model-delivery-mobile.png', fullPage: true });
|
||||||
|
expect(browserErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates an offline customer and assigned address before its delivery, then syncs in order', async ({ page, context }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await openDeliveryView(page);
|
||||||
|
await expect(page.getByRole('combobox', { name: 'סוג משלוח', exact: true }).locator('option')).toHaveCount(3);
|
||||||
|
await context.setOffline(true);
|
||||||
|
await expect(page.locator('.connection')).toContainText('ללא חיבור לרשת');
|
||||||
|
await page.getByRole('button', { name: 'לקוחות', exact: true }).click();
|
||||||
|
const name = `לקוח בדיקה ${Date.now()}`;
|
||||||
|
await page.getByRole('button', { name: 'לקוח חדש', exact: true }).first().click();
|
||||||
|
await page.getByLabel('שם הלקוח').fill(name);
|
||||||
|
await page.getByLabel('איש קשר').fill('איש קשר לבדיקה');
|
||||||
|
await page.getByLabel('מספר טלפון').fill('050-1234567');
|
||||||
|
await page.getByLabel('בוקר', { exact: true }).check();
|
||||||
|
await page.getByRole('button', { name: 'יצירת לקוח', exact: true }).click();
|
||||||
|
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible();
|
||||||
|
await page.getByLabel('שם הכתובת').fill('ראשית');
|
||||||
|
await page.getByLabel('עיר', { exact: true }).fill('תל אביב');
|
||||||
|
await page.getByLabel('רחוב', { exact: true }).fill('הבדיקה');
|
||||||
|
await page.getByLabel('מספר', { exact: true }).fill('7');
|
||||||
|
await page.getByRole('button', { name: 'הוספת כתובת', exact: true }).click();
|
||||||
|
await expect(page.getByText('הבדיקה 7, תל אביב', { exact: true })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'משלוחים', exact: true }).click();
|
||||||
|
await page.getByRole('combobox', { name: 'לקוח', exact: true }).selectOption({ label: name });
|
||||||
|
await page.getByRole('combobox', { name: 'כתובת למשלוח', exact: true }).selectOption({ index: 1 });
|
||||||
|
await page.getByRole('button', { name: '+ שמירת משלוח', exact: true }).click();
|
||||||
|
const row = page.locator('.delivery-row').filter({ hasText: name });
|
||||||
|
await expect(row).toContainText('ממתין לסנכרון');
|
||||||
|
await context.setOffline(false);
|
||||||
|
await expect(row).toContainText('מסונכרן');
|
||||||
|
await expect(row).toContainText('#');
|
||||||
|
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edits and archives a customer while preserving related deliveries', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: 'לקוחות', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'לקוח חדש', exact: true }).first().click();
|
||||||
|
const name = `מרפאת פרופיל ${Date.now()}`;
|
||||||
|
await page.getByLabel('שם הלקוח').fill(name);
|
||||||
|
await page.getByLabel('איש קשר').fill('נועה כהן');
|
||||||
|
await page.getByLabel('מספר טלפון').fill('052-7654321');
|
||||||
|
await page.getByLabel('בוקר', { exact: true }).check();
|
||||||
|
await page.getByLabel('תחילת בוקר').fill('08:15');
|
||||||
|
await page.getByLabel('סיום בוקר').fill('11:45');
|
||||||
|
await page.getByLabel('הערות ללקוח').fill('כניסה מהחניון');
|
||||||
|
await page.getByRole('button', { name: 'יצירת לקוח' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible();
|
||||||
|
const clientRow = page.locator('.client-row').filter({ hasText: name });
|
||||||
|
await expect(clientRow.locator('.sync-dot')).toHaveClass(/synced/);
|
||||||
|
await page.getByLabel('שם הכתובת').fill('מרפאה');
|
||||||
|
await page.getByLabel('עיר', { exact: true }).fill('חיפה');
|
||||||
|
await page.getByLabel('רחוב', { exact: true }).fill('הנמל');
|
||||||
|
await page.getByLabel('מספר', { exact: true }).fill('12');
|
||||||
|
await page.getByRole('button', { name: 'הוספת כתובת' }).click();
|
||||||
|
await expect(page.locator('.associated-row').filter({ hasText: 'הנמל 12, חיפה' }).last().locator('.sync-dot')).toHaveClass(/synced/);
|
||||||
|
await page.getByRole('button', { name: 'משלוחים', exact: true }).click();
|
||||||
|
await page.getByRole('combobox', { name: 'לקוח', exact: true }).selectOption({ label: name });
|
||||||
|
await page.getByRole('combobox', { name: 'כתובת למשלוח', exact: true }).selectOption({ index: 1 });
|
||||||
|
await page.getByRole('button', { name: '+ שמירת משלוח' }).click();
|
||||||
|
await expect(page.locator('.delivery-row').filter({ hasText: name })).toContainText('מסונכרן');
|
||||||
|
await page.getByRole('button', { name: 'לקוחות', exact: true }).click();
|
||||||
|
await clientRow.click();
|
||||||
|
await expect(page.getByText(/משלוח #/)).toBeVisible();
|
||||||
|
await expect(page.getByText('הנמל 12, חיפה', { exact: true })).toBeVisible();
|
||||||
|
await page.screenshot({ path: 'test-results/model-delivery-clients.png' });
|
||||||
|
await page.getByRole('button', { name: 'עריכה', exact: true }).click();
|
||||||
|
await page.getByLabel('איש קשר').fill('נועה לוי');
|
||||||
|
await page.getByRole('button', { name: 'שמירת שינויים' }).click();
|
||||||
|
await expect(page.locator('.client-summary strong').filter({ hasText: 'נועה לוי' })).toBeVisible();
|
||||||
|
await expect(clientRow.locator('.sync-dot')).toHaveClass(/synced/);
|
||||||
|
await page.getByRole('button', { name: 'הסרה', exact: true }).click();
|
||||||
|
await expect(page.getByRole('alert')).toContainText('יישמרו ויישארו משויכים');
|
||||||
|
await page.getByRole('button', { name: 'העברה לארכיון' }).click();
|
||||||
|
await page.getByLabel('הצגת ארכיון').check();
|
||||||
|
await expect(clientRow).toBeVisible();
|
||||||
|
await clientRow.click();
|
||||||
|
await expect(page.getByText(/משלוח #/)).toBeVisible();
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||||
|
await expect(page.locator('.sidebar')).not.toHaveClass(/open/);
|
||||||
|
await expect.poll(() => page.locator('.sidebar').evaluate(element => { const rect = element.getBoundingClientRect(); return rect.left >= innerWidth || rect.right <= 0; })).toBe(true);
|
||||||
|
await page.screenshot({ path: 'test-results/model-delivery-clients-mobile.png' });
|
||||||
|
await page.getByRole('button', { name: 'פתיחת תפריט' }).click();
|
||||||
|
await expect(page.locator('.sidebar')).toHaveClass(/open/);
|
||||||
|
await page.getByRole('button', { name: 'משלוחים', exact: true }).click();
|
||||||
|
await expect(page.getByRole('combobox', { name: 'לקוח', exact: true }).locator('option', { hasText: name })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps an offline edit across reload, resolves a concurrent edit explicitly, then cancels', async ({ page, context }) => {
|
||||||
|
const request = context.request;
|
||||||
|
await page.goto('/');
|
||||||
|
await openDeliveryView(page);
|
||||||
|
await expect(page.getByRole('combobox', { name: 'סוג משלוח', exact: true }).locator('option')).toHaveCount(3);
|
||||||
|
await page.evaluate(async () => { await navigator.serviceWorker.ready; });
|
||||||
|
await page.reload();
|
||||||
|
await openDeliveryView(page);
|
||||||
|
const conflictNotes = `Conflict browser test ${Date.now()}`;
|
||||||
|
await deliveryForm(page, conflictNotes);
|
||||||
|
await page.getByRole('button', { name: '+ שמירת משלוח', exact: true }).click();
|
||||||
|
await expect(page.locator('.detail-panel')).toContainText(conflictNotes);
|
||||||
|
const row = page.locator('.delivery-row[aria-pressed="true"]');
|
||||||
|
await expect(row).toContainText('מסונכרן');
|
||||||
|
const rowId = (await row.getAttribute('data-testid'))!;
|
||||||
|
const number = await row.locator('.row-top strong').innerText();
|
||||||
|
await context.setOffline(true);
|
||||||
|
await page.getByRole('button', { name: 'עריכת משלוח', exact: true }).click();
|
||||||
|
await page.getByLabel('הערות מעודכנות').fill('Offline edit to keep');
|
||||||
|
await page.getByRole('button', { name: 'שמירת שינויים', exact: true }).click();
|
||||||
|
await expect(page.getByTestId(rowId)).toContainText('ממתין לסנכרון');
|
||||||
|
await page.reload();
|
||||||
|
await openDeliveryView(page);
|
||||||
|
await page.getByTestId(rowId).click();
|
||||||
|
await expect(page.locator('.detail-panel')).toContainText('Offline edit to keep');
|
||||||
|
const session = await (await request.get('/api/auth/session')).json();
|
||||||
|
const headers = { 'x-model-request': '1', 'x-model-user': session.user.id };
|
||||||
|
const remote = await request.post('/api/sync/push', { headers, data: { operationId: randomUUID(), deviceId: randomUUID(), entityId: rowId.replace('delivery-', ''), baseVersion: 1, kind: 'delivery.update', data: { customerId: '20000000-0000-4000-8000-000000000001', addressId: '20000000-0000-4000-8000-000000000002', notes: 'Other device edit' } } });
|
||||||
|
expect(remote.ok()).toBe(true);
|
||||||
|
await context.setOffline(false);
|
||||||
|
const panel = page.getByRole('region', { name: 'פתרון התנגשות' });
|
||||||
|
await expect(panel).toBeVisible();
|
||||||
|
await expect(panel).toContainText('Offline edit to keep');
|
||||||
|
await expect(panel).toContainText('Other device edit');
|
||||||
|
await page.getByRole('button', { name: 'החלת השינוי שלי על הגרסה החדשה' }).click();
|
||||||
|
await expect(page.getByTestId(rowId)).toContainText('מסונכרן');
|
||||||
|
await expect(panel).toHaveCount(0);
|
||||||
|
await expect(page.locator('.detail-panel')).toContainText('Offline edit to keep');
|
||||||
|
await context.setOffline(true);
|
||||||
|
await page.getByRole('button', { name: 'ביטול משלוח', exact: true }).click();
|
||||||
|
await page.getByLabel('סיבת הביטול').fill('Cancelled in browser test');
|
||||||
|
await page.getByRole('button', { name: 'אישור ביטול משלוח', exact: true }).click();
|
||||||
|
await expect(page.getByTestId(rowId)).toContainText('מבוטל');
|
||||||
|
await page.reload();
|
||||||
|
await openDeliveryView(page);
|
||||||
|
await page.getByTestId(rowId).click();
|
||||||
|
await expect(page.getByTestId(rowId)).toContainText('ממתין לסנכרון');
|
||||||
|
await context.setOffline(false);
|
||||||
|
await expect(page.getByTestId(rowId)).toContainText('מסונכרן');
|
||||||
|
await expect(page.getByTestId(rowId)).toContainText(number);
|
||||||
|
await expect(page.getByRole('button', { name: 'עריכת משלוח', exact: true })).toBeDisabled();
|
||||||
|
await expect(page.locator('.detail-panel')).toContainText('15.02');
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Only used against model_delivery_test. This is not a default application account.
|
||||||
|
export const testAccount = { username: 'test.admin', password: 'Test-only-password-932!', displayName: 'משתמש בדיקה' };
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Test-only identity provider. Never imported or started by application code.
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { generateKeyPair, exportJWK, SignJWT } from 'jose';
|
||||||
|
|
||||||
|
export async function startMockOidc(port = 0) {
|
||||||
|
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
||||||
|
const jwk = { ...await exportJWK(publicKey), kid: 'test-key', use: 'sig', alg: 'RS256' };
|
||||||
|
const codes = new Map(), refreshTokens = new Set(), accessTokens = new Set();
|
||||||
|
const controls = { badNonce: false, denyRefresh: false, refreshCount: 0, omitName: false, omitRefreshToken: false, subject: 'synology-test-user', groups: ['model-delivery-users'] };
|
||||||
|
let issuer = '';
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
const profile = { sub: controls.subject, username: 'test.sso', ...(controls.omitName ? {} : { name: 'טום בדיקה' }), email: 'test@example.invalid', groups: controls.groups };
|
||||||
|
const json = (data, status = 200) => { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(data)); };
|
||||||
|
const url = new URL(req.url, issuer);
|
||||||
|
try {
|
||||||
|
if (url.pathname === '/.well-known/openid-configuration') return json({ issuer, authorization_endpoint: `${issuer}/authorize`, token_endpoint: `${issuer}/token`, userinfo_endpoint: `${issuer}/userinfo`, jwks_uri: `${issuer}/jwks`, response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['RS256'], token_endpoint_auth_methods_supported: ['client_secret_post'], scopes_supported: ['openid', 'profile', 'email', 'groups', 'offline_access'], claims_supported: ['sub', 'username', 'name', 'email', 'groups'], code_challenge_methods_supported: ['S256'] });
|
||||||
|
if (url.pathname === '/jwks') return json({ keys: [jwk] });
|
||||||
|
if (url.pathname === '/authorize') {
|
||||||
|
const values = Object.fromEntries(url.searchParams);
|
||||||
|
if (values.client_id !== 'model-test' || values.redirect_uri !== 'http://localhost:4174/api/auth/callback' || values.code_challenge_method !== 'S256' || !values.code_challenge || !values.state || !values.nonce) return json({ error: 'invalid_request' }, 400);
|
||||||
|
const code = randomBytes(24).toString('hex'); codes.set(code, values);
|
||||||
|
const redirect = new URL(values.redirect_uri); redirect.searchParams.set('code', code); redirect.searchParams.set('state', values.state);
|
||||||
|
res.writeHead(302, { location: redirect.href }); return res.end();
|
||||||
|
}
|
||||||
|
if (url.pathname === '/token' && req.method === 'POST') {
|
||||||
|
let raw = ''; for await (const chunk of req) raw += chunk;
|
||||||
|
const fields = new URLSearchParams(raw);
|
||||||
|
if (fields.get('client_id') !== 'model-test' || fields.get('client_secret') !== 'mock-test-secret') return json({ error: 'invalid_client' }, 401);
|
||||||
|
let values;
|
||||||
|
if (fields.get('grant_type') === 'authorization_code') {
|
||||||
|
values = codes.get(fields.get('code')); codes.delete(fields.get('code'));
|
||||||
|
if (!values || values.redirect_uri !== fields.get('redirect_uri') || createHash('sha256').update(fields.get('code_verifier') ?? '').digest('base64url') !== values.code_challenge) return json({ error: 'invalid_grant' }, 400);
|
||||||
|
} else if (fields.get('grant_type') === 'refresh_token') {
|
||||||
|
if (controls.denyRefresh || !refreshTokens.delete(fields.get('refresh_token'))) return json({ error: 'invalid_grant' }, 400);
|
||||||
|
controls.refreshCount++;
|
||||||
|
} else return json({ error: 'unsupported_grant_type' }, 400);
|
||||||
|
const access_token = randomBytes(32).toString('hex'), refresh_token = randomBytes(32).toString('hex');
|
||||||
|
accessTokens.add(access_token); refreshTokens.add(refresh_token);
|
||||||
|
const id_token = values ? await new SignJWT({ ...profile, nonce: controls.badNonce ? 'wrong-nonce' : values.nonce }).setProtectedHeader({ alg: 'RS256', kid: jwk.kid }).setIssuer(issuer).setAudience('model-test').setIssuedAt().setExpirationTime('1h').sign(privateKey) : undefined;
|
||||||
|
return json({ access_token, ...(!controls.omitRefreshToken ? { refresh_token } : {}), id_token, token_type: 'Bearer', expires_in: 3600, scope: 'openid profile email groups offline_access' });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/userinfo' && accessTokens.has(req.headers.authorization?.replace(/^Bearer /, ''))) return json(profile);
|
||||||
|
return json({ error: 'not_found' }, 404);
|
||||||
|
} catch { return json({ error: 'server_error' }, 500); }
|
||||||
|
});
|
||||||
|
await new Promise(resolve => server.listen(port, '127.0.0.1', resolve));
|
||||||
|
issuer = `http://localhost:${server.address().port}`;
|
||||||
|
return { issuer, controls, close: () => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) };
|
||||||
|
}
|
||||||
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
const provider = await startMockOidc(4005);
|
||||||
|
console.log(`Test OIDC provider ready at ${provider.issuer}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { expect, it } from 'vitest';
|
||||||
|
import type { LocalDelivery } from '../../src/db';
|
||||||
|
import { activeDeliveries, deliveriesForDate, deliveryDateKey, toDateKey, valueOf, weekDates } from '../../src/analytics';
|
||||||
|
|
||||||
|
function delivery(overrides: Partial<LocalDelivery> = {}): LocalDelivery {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(), customerId: crypto.randomUUID(), addressId: crypto.randomUUID(), deliveryTypeId: crypto.randomUUID(),
|
||||||
|
deliveryTypeVersion: 1, shipmentUnitHundredths: 100, unitPriceAgorot: 1000, totalPriceAgorot: 1000,
|
||||||
|
deliveryNumber: 1001, scheduledDate: '2026-09-06', deliverySlot: 'afternoon', specificTime: null, deliveryWindow: null, notes: '', status: 'created', version: 1,
|
||||||
|
createdAt: '2026-09-04T10:00:00.000Z', updatedAt: null, cancelledAt: null, cancellationReason: null,
|
||||||
|
deletedAt: null, syncState: 'synced', ...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('builds an Israeli Sunday-to-Saturday week without timezone drift', () => {
|
||||||
|
const days = weekDates(new Date(2026, 8, 9, 23));
|
||||||
|
expect(days.map(toDateKey)).toEqual(['2026-09-06', '2026-09-07', '2026-09-08', '2026-09-09', '2026-09-10', '2026-09-11', '2026-09-12']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the planned date and excludes cancelled deliveries from value totals', () => {
|
||||||
|
const legacy = delivery({ scheduledDate: null });
|
||||||
|
const active = delivery();
|
||||||
|
const cancelled = delivery({ status: 'cancelled', totalPriceAgorot: 5000 });
|
||||||
|
expect(deliveryDateKey(legacy)).toBe('2026-09-04');
|
||||||
|
expect(deliveriesForDate([legacy, active, cancelled], '2026-09-06')).toHaveLength(2);
|
||||||
|
expect(activeDeliveries([active, cancelled])).toEqual([active]);
|
||||||
|
expect(valueOf([active, cancelled])).toBe(1000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import 'fake-indexeddb/auto';
|
||||||
|
import { afterEach, beforeEach, expect, it } from 'vitest';
|
||||||
|
import { deliverySchema, type Change } from '@model/shared';
|
||||||
|
import { ModelDatabase, changeDelivery, resolveConflict, storeChange } from '../../src/db';
|
||||||
|
import { AuthenticationError, runSync } from '../../src/sync';
|
||||||
|
|
||||||
|
let database: ModelDatabase;
|
||||||
|
const customerId = crypto.randomUUID(), addressId = crypto.randomUUID(), id = crypto.randomUUID();
|
||||||
|
const meta = { createdAt: new Date().toISOString(), deletedAt: null, version: 1 };
|
||||||
|
const original = deliverySchema.parse({ ...meta, id, customerId, addressId, deliverySlot: 'morning', specificTime: null, deliveryWindow: { start: '08:00', end: '11:00' }, deliveryTypeId: crypto.randomUUID(), deliveryTypeVersion: 1, shipmentUnitHundredths: 150, unitPriceAgorot: 1001, totalPriceAgorot: 1502, deliveryNumber: 1201, notes: 'Original', status: 'created' });
|
||||||
|
beforeEach(async () => {
|
||||||
|
database = new ModelDatabase(`changes-${crypto.randomUUID()}`);
|
||||||
|
await storeChange(database, { entityType: 'customer', record: { ...meta, id: customerId, name: 'Test' } });
|
||||||
|
await storeChange(database, { entityType: 'address', record: { ...meta, id: addressId, customerId, label: 'Main', address: 'Test address' } });
|
||||||
|
await storeChange(database, { entityType: 'delivery', record: original });
|
||||||
|
});
|
||||||
|
afterEach(async () => { await database.delete(); });
|
||||||
|
it('persists an offline edit with the original base version and immutable pricing', async () => {
|
||||||
|
await changeDelivery(id, 1, 'delivery.update', { customerId, addressId, deliverySlot: 'custom', specificTime: '14:35', deliveryWindow: null, notes: 'Offline edit' }, database);
|
||||||
|
database.close(); await database.open();
|
||||||
|
expect(await database.deliveries.get(id)).toMatchObject({ notes: 'Offline edit', deliverySlot: 'custom', specificTime: '14:35', deliveryWindow: null, syncState: 'pending', version: 1, deliveryNumber: 1201, totalPriceAgorot: 1502 });
|
||||||
|
expect((await database.outbox.toArray())[0]!.operation.baseVersion).toBe(1);
|
||||||
|
await expect(changeDelivery(id, 1, 'delivery.cancel', { reason: 'Too soon' }, database)).rejects.toThrow('סנכרון');
|
||||||
|
});
|
||||||
|
it('rejects a stale edit form instead of overwriting a newer pulled record', async () => {
|
||||||
|
await storeChange(database, { entityType: 'delivery', record: { ...original, version: 2, notes: 'Remote' } });
|
||||||
|
await expect(changeDelivery(id, 1, 'delivery.update', { customerId, addressId, notes: 'Stale form' }, database)).rejects.toThrow('השתנה');
|
||||||
|
expect(await database.outbox.count()).toBe(0);
|
||||||
|
});
|
||||||
|
it('cancels locally without removing the record, business number or snapshot', async () => {
|
||||||
|
await changeDelivery(id, 1, 'delivery.cancel', { reason: 'Customer requested' }, database);
|
||||||
|
expect(await database.deliveries.get(id)).toMatchObject({ status: 'cancelled', deliveryNumber: 1201, unitPriceAgorot: 1001, cancellationReason: 'Customer requested', syncState: 'pending' });
|
||||||
|
});
|
||||||
|
it('keeps the exact pending operation on 401 rather than marking it rejected', async () => {
|
||||||
|
await changeDelivery(id, 1, 'delivery.update', { customerId, addressId, notes: 'Waiting for login' }, database);
|
||||||
|
const entry = (await database.outbox.toArray())[0]!;
|
||||||
|
await expect(runSync(database, async () => Response.json({ code: 'AUTH_REQUIRED' }, { status: 401 }))).rejects.toBeInstanceOf(AuthenticationError);
|
||||||
|
expect((await database.outbox.toArray())[0]).toEqual(entry);
|
||||||
|
});
|
||||||
|
it('requires explicit resolution, resubmits with a new operation ID, and preserves the local intended patch', async () => {
|
||||||
|
await changeDelivery(id, 1, 'delivery.update', { customerId, addressId, notes: 'My edit' }, database);
|
||||||
|
const op = (await database.outbox.toArray())[0]!;
|
||||||
|
const server: Change = { entityType: 'delivery', record: { ...original, version: 2, notes: 'Another device' } };
|
||||||
|
await runSync(database, async url => url.endsWith('/push') ? Response.json({ code: 'VERSION_CONFLICT', current: server }, { status: 409 }) : Response.json({ changes: [{ sequence: '2', change: server }], nextCursor: '2', hasMore: false }));
|
||||||
|
expect((await database.deliveries.get(id))?.notes).toBe('My edit');
|
||||||
|
await expect(resolveConflict(op.operationId, 1, 'local', database)).rejects.toThrow('חדשה');
|
||||||
|
await resolveConflict(op.operationId, 2, 'local', database);
|
||||||
|
const replacement = (await database.outbox.toArray())[0]!;
|
||||||
|
expect(replacement.operationId).not.toBe(op.operationId);
|
||||||
|
expect(replacement.operation.baseVersion).toBe(2);
|
||||||
|
expect(replacement.operation.data).toMatchObject({ notes: 'My edit' });
|
||||||
|
});
|
||||||
|
it('accepts server cancellation explicitly and refuses to resurrect it', async () => {
|
||||||
|
await changeDelivery(id, 1, 'delivery.update', { customerId, addressId, notes: 'My edit' }, database);
|
||||||
|
const op = (await database.outbox.toArray())[0]!;
|
||||||
|
const server = { ...original, version: 2, status: 'cancelled' as const, cancellationReason: 'Closed', cancelledAt: new Date().toISOString() };
|
||||||
|
await database.outbox.update(op.queueId!, { state: 'error', serverConflict: { entityType: 'delivery', record: server } });
|
||||||
|
await expect(resolveConflict(op.operationId, 2, 'local', database)).rejects.toThrow();
|
||||||
|
await resolveConflict(op.operationId, 2, 'server', database);
|
||||||
|
expect(await database.outbox.count()).toBe(0);
|
||||||
|
expect(await database.deliveries.get(id)).toMatchObject({ status: 'cancelled', syncState: 'synced', version: 2 });
|
||||||
|
await storeChange(database, { entityType: 'delivery', record: original });
|
||||||
|
expect((await database.deliveries.get(id))?.status).toBe('cancelled');
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import 'fake-indexeddb/auto';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { changeCustomer, ModelDatabase, resolveCustomerConflict, saveLocal, storeChange } from '../../src/db';
|
||||||
|
import { createSyncCoordinator, runSync, type Fetcher } from '../../src/sync';
|
||||||
|
import { operationSchema, totalPrice, type Change } from '@model/shared';
|
||||||
|
|
||||||
|
let db: ModelDatabase;
|
||||||
|
const customerId = crypto.randomUUID();
|
||||||
|
const addressId = crypto.randomUUID();
|
||||||
|
const typeId = crypto.randomUUID();
|
||||||
|
const meta = { version: 1, createdAt: new Date().toISOString(), deletedAt: null, updatedAt: null, cancelledAt: null, cancellationReason: null };
|
||||||
|
const deliveryData = { customerId, addressId, scheduledDate: null, deliverySlot: 'morning' as const, specificTime: null, deliveryWindow: { start: '08:00', end: '11:00' }, deliveryTypeId: typeId, deliveryTypeVersion: 1, shipmentUnitHundredths: 150, unitPriceAgorot: 1001, notes: 'בדיקה' };
|
||||||
|
beforeEach(async () => {
|
||||||
|
db = new ModelDatabase(`test-${crypto.randomUUID()}`);
|
||||||
|
await storeChange(db, { entityType: 'customer', record: { id: customerId, name: 'Test', ...meta } });
|
||||||
|
await storeChange(db, { entityType: 'address', record: { id: addressId, customerId, label: 'Main', address: 'Test address', ...meta } });
|
||||||
|
});
|
||||||
|
afterEach(async () => { await db.delete(); });
|
||||||
|
describe('durable local writes and recovery', () => {
|
||||||
|
it('honors reconnect while an earlier offline request is still failing', async () => {
|
||||||
|
let rejectFirst!: (error: Error) => void;
|
||||||
|
const firstRequest = new Promise<void>((_resolve, reject) => { rejectFirst = reject; });
|
||||||
|
let attempts = 0;
|
||||||
|
const sync = createSyncCoordinator(async () => { if (++attempts === 1) await firstRequest; });
|
||||||
|
const startup = sync();
|
||||||
|
const reconnect = sync();
|
||||||
|
rejectFirst(new Error('The old offline request failed'));
|
||||||
|
await expect(startup).resolves.toBeUndefined();
|
||||||
|
await expect(reconnect).resolves.toBeUndefined();
|
||||||
|
expect(attempts).toBe(2);
|
||||||
|
});
|
||||||
|
it('saves entity and outbox offline, survives reopen and reuses the same operation after lost response', async () => {
|
||||||
|
const id = await saveLocal('delivery.create', deliveryData, db);
|
||||||
|
const [entry] = await db.outbox.toArray();
|
||||||
|
expect(await db.deliveries.get(id)).toMatchObject({ deliveryNumber: null, syncState: 'pending', totalPriceAgorot: 1502, deliverySlot: 'morning', deliveryWindow: { start: '08:00', end: '11:00' } });
|
||||||
|
db.close(); await db.open();
|
||||||
|
const attempts: string[] = [];
|
||||||
|
const change: Change = { entityType: 'delivery', record: { ...deliveryData, ...meta, id, deliveryNumber: 1001, totalPriceAgorot: 1502, status: 'created' } };
|
||||||
|
const lostResponse: Fetcher = async (_url, init) => { attempts.push(JSON.parse(String(init?.body)).operationId); throw new Error('Response lost after commit'); };
|
||||||
|
await expect(runSync(db, lostResponse)).rejects.toThrow();
|
||||||
|
expect(await db.outbox.count()).toBe(1);
|
||||||
|
const replay: Fetcher = async (url, init) => {
|
||||||
|
if (url.endsWith('/push')) { attempts.push(JSON.parse(String(init?.body)).operationId); return Response.json({ change, replayed: true }); }
|
||||||
|
expect(url).toContain('cursor=0');
|
||||||
|
return Response.json({ changes: [{ sequence: '1', change }], nextCursor: '1', hasMore: false });
|
||||||
|
};
|
||||||
|
await runSync(db, replay);
|
||||||
|
expect(attempts).toEqual([entry!.operationId, entry!.operationId]);
|
||||||
|
expect(await db.outbox.count()).toBe(0);
|
||||||
|
expect(await db.deliveries.get(id)).toMatchObject({ id, deliveryNumber: 1001, syncState: 'synced' });
|
||||||
|
});
|
||||||
|
it('rolls back the local entity if the outbox insert fails', async () => {
|
||||||
|
db.outbox.hook('creating', () => { throw new Error('Disk full'); });
|
||||||
|
await expect(saveLocal('delivery.create', deliveryData, db)).rejects.toThrow('Disk full');
|
||||||
|
expect(await db.deliveries.count()).toBe(0);
|
||||||
|
expect(await db.outbox.count()).toBe(0);
|
||||||
|
});
|
||||||
|
it('rejects addresses assigned to another customer without saving an operation', async () => {
|
||||||
|
await expect(saveLocal('delivery.create', { ...deliveryData, customerId: crypto.randomUUID() }, db)).rejects.toThrow();
|
||||||
|
expect(await db.outbox.count()).toBe(0);
|
||||||
|
});
|
||||||
|
it('preserves a conflicting draft when the pull feed contains the server copy', async () => {
|
||||||
|
const id = await saveLocal('delivery.create', deliveryData, db);
|
||||||
|
const change: Change = { entityType: 'delivery', record: { ...deliveryData, ...meta, id, notes: 'Different server copy', deliveryNumber: 2001, totalPriceAgorot: 1502, status: 'created' } };
|
||||||
|
await runSync(db, async url => url.endsWith('/push') ? Response.json({ code: 'VERSION_CONFLICT', current: change }, { status: 409 }) : Response.json({ changes: [{ sequence: '1', change }], nextCursor: '1', hasMore: false }));
|
||||||
|
expect(await db.deliveries.get(id)).toMatchObject({ notes: 'בדיקה', syncState: 'error', deliveryNumber: null });
|
||||||
|
expect((await db.outbox.toArray())[0]?.serverConflict).toEqual(change);
|
||||||
|
});
|
||||||
|
it('applies tombstones and paginated changes with a durable cursor', async () => {
|
||||||
|
const first: Change = { entityType: 'customer', record: { id: customerId, name: 'Deleted', createdAt: meta.createdAt, version: 2, deletedAt: meta.createdAt } };
|
||||||
|
const second: Change = { entityType: 'address', record: { id: addressId, customerId, label: 'Deleted', address: 'Test', createdAt: meta.createdAt, version: 2, deletedAt: meta.createdAt } };
|
||||||
|
const cursors: string[] = [];
|
||||||
|
await runSync(db, async url => {
|
||||||
|
cursors.push(url);
|
||||||
|
return Response.json(url.endsWith('=0') ? { changes: [{ sequence: '5', change: first }], nextCursor: '5', hasMore: true } : { changes: [{ sequence: '8', change: second }], nextCursor: '8', hasMore: false });
|
||||||
|
});
|
||||||
|
expect(cursors).toEqual(['/api/sync/pull?cursor=0', '/api/sync/pull?cursor=5']);
|
||||||
|
expect((await db.customers.get(customerId))?.deletedAt).toBe(meta.createdAt);
|
||||||
|
expect((await db.meta.get('cursor'))?.value).toBe('8');
|
||||||
|
});
|
||||||
|
it('rejects nonzero create versions and rounds fixed-point totals', () => {
|
||||||
|
expect(operationSchema.safeParse({ baseVersion: 1, kind: 'delivery.create' }).success).toBe(false);
|
||||||
|
expect(operationSchema.safeParse({ operationId: crypto.randomUUID(), deviceId: crypto.randomUUID(), entityId: crypto.randomUUID(), baseVersion: 0, kind: 'delivery.create', data: { ...deliveryData, deliverySlot: 'custom', specificTime: null, deliveryWindow: null } }).success).toBe(false);
|
||||||
|
expect(totalPrice(101, 150)).toBe(152);
|
||||||
|
});
|
||||||
|
it('stores customer edits and archives atomically, with explicit conflict resolution', async () => {
|
||||||
|
const details = { name: 'Updated clinic', contactName: 'תום רוף', phone: '050-1234567', comments: 'Ring at reception', deliveryWindows: { morning: { start: '08:30', end: '11:45' }, afternoon: null, lateNight: null } };
|
||||||
|
await changeCustomer(customerId, 1, 'customer.update', details, db);
|
||||||
|
const pending = (await db.outbox.toArray())[0]!;
|
||||||
|
expect(await db.customers.get(customerId)).toMatchObject({ ...details, syncState: 'pending' });
|
||||||
|
const server: Change = { entityType: 'customer', record: { id: customerId, ...details, name: 'Server clinic', version: 2, createdAt: meta.createdAt, deletedAt: null, updatedAt: meta.createdAt } };
|
||||||
|
await db.outbox.update(pending.queueId!, { state: 'error', serverConflict: server });
|
||||||
|
await resolveCustomerConflict(pending.operationId, 2, 'local', db);
|
||||||
|
const retry = (await db.outbox.toArray())[0]!;
|
||||||
|
expect(retry.operation).toMatchObject({ kind: 'customer.update', baseVersion: 2, data: details });
|
||||||
|
await db.outbox.clear();
|
||||||
|
await storeChange(db, server);
|
||||||
|
await changeCustomer(customerId, 2, 'customer.remove', {}, db);
|
||||||
|
expect((await db.customers.get(customerId))?.deletedAt).toBeTruthy();
|
||||||
|
expect((await db.outbox.toArray())[0]?.operation.kind).toBe('customer.remove');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"noEmit": true,
|
||||||
|
"types": [
|
||||||
|
"vite/client",
|
||||||
|
"vite-plugin-pwa/client"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src",
|
||||||
|
"vite.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
const env = loadEnv('development', fileURLToPath(new URL('./', import.meta.url)), '');
|
||||||
|
const apiTarget = `http://127.0.0.1:${process.env.API_PORT ?? env.API_PORT ?? 3001}`;
|
||||||
|
const proxy = { '/api': { target: apiTarget, rewrite: (path: string) => path.replace(/^\/api/, '') } };
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), VitePWA({
|
||||||
|
registerType: 'prompt',
|
||||||
|
includeAssets: ['favicon.ico', 'favicon-32.png', 'favicon-48.png', 'brand-logo.png', 'brand-logo.webp', 'icon-192.png', 'icon-512.png', 'icon-maskable-512.png', 'apple-touch-icon.png'],
|
||||||
|
manifest: {
|
||||||
|
name: 'מודל משלוחים', short_name: 'משלוחים', description: 'ניהול משלוחי מעבדת שיניים', lang: 'he', dir: 'rtl',
|
||||||
|
theme_color: '#2D3845', background_color: '#f8f3ed', display: 'standalone', start_url: '/', scope: '/',
|
||||||
|
icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, { src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }],
|
||||||
|
},
|
||||||
|
workbox: { globPatterns: ['**/*.{js,css,html,png,webp,ico,woff2}'], navigateFallbackDenylist: [/^\/api(?:\/|$)/], cleanupOutdatedCaches: true },
|
||||||
|
devOptions: { enabled: false },
|
||||||
|
})],
|
||||||
|
server: { port: 5173, strictPort: true, proxy },
|
||||||
|
preview: { port: 4173, strictPort: true, proxy },
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
export default defineConfig({ test: { include: ['tests/unit/**/*.test.ts'], environment: 'node' } });
|
||||||