ui phase 9

This commit is contained in:
hamid
2026-07-19 15:14:44 +03:30
parent 1ef4feb911
commit b638e25a0e
47 changed files with 2628 additions and 666 deletions
@@ -40,3 +40,14 @@ export function cityCentroid(cityId?: number | null): { latitude: number; longit
if (cityId != null && CITY_CENTROIDS[cityId]) return CITY_CENTROIDS[cityId];
return IRAN_CENTROID;
}
/**
* Neshan raster tile URL template (Leaflet `L.tileLayer` `{z}/{x}/{y}` placeholders + `{key}` for
* `NEXT_PUBLIC_NESHAN_KEY`). Set from Neshan's web-SDK conventions without live verification (this
* repo's sandbox has no route to platform.neshan.org) — **re-check against the current Neshan
* developer portal (platform.neshan.org) before relying on it with a real key**; if the host/path
* has moved, only this constant needs to change; `NeshanMap` never hardcodes the URL.
*/
export const NESHAN_TILE_URL_TEMPLATE = 'https://static.neshan.org/atlas/style/standard-day/{z}/{x}/{y}.png?key={key}';
export const NESHAN_TILE_ATTRIBUTION = '© Neshan';
export const NESHAN_DEFAULT_ZOOM = 15;
+65
View File
@@ -0,0 +1,65 @@
import { NESHAN_WEB_KEY } from '@/config';
import type { LatLng } from '@/services/addresses/types';
/**
* Thin client for Neshan's public Search + Reverse-geocoding REST APIs (`api.neshan.org`) — the
* address map's search box and reverse-geocoded pin preview (ui-phase-9). This is a **direct
* third-party call, not our backend**: `clientFetch` is deliberately NOT used here, since it would
* prepend `NEXT_PUBLIC_API_URL` and attach our own bearer token to a third-party request (a
* credential leak) — a plain `fetch` with Neshan's own `Api-Key` header is the correct boundary.
*
* Endpoint paths/headers are confirmed against Neshan's public documentation; the exact response
* field names below (`items[].location.x/y`, `formatted_address`) were **not live-verified** in
* this sandbox (no network route to platform.neshan.org) — every read here is defensive
* (optional-chained, wrapped in try/catch) so a shape drift degrades to "no results" rather than a
* crash. Re-verify against platform.neshan.org if search/reverse silently return nothing with a
* real key configured.
*/
const SEARCH_URL = 'https://api.neshan.org/v1/search';
const REVERSE_URL = 'https://api.neshan.org/v5/reverse';
export interface NeshanSearchResult {
title: string;
address: string;
location: LatLng;
}
async function neshanGet(url: string): Promise<unknown> {
if (!NESHAN_WEB_KEY) return null;
try {
const response = await fetch(url, { headers: { 'Api-Key': NESHAN_WEB_KEY } });
if (!response.ok) return null;
return await response.json();
} catch {
return null;
}
}
/** Free-text address search near a point (the map's current center) — `[]` on any failure. */
export async function searchNeshan(term: string, near: LatLng): Promise<NeshanSearchResult[]> {
const trimmed = term.trim();
if (!trimmed) return [];
const url = `${SEARCH_URL}?term=${encodeURIComponent(trimmed)}&lat=${near.latitude}&lng=${near.longitude}`;
const data = (await neshanGet(url)) as { items?: unknown[] } | null;
if (!Array.isArray(data?.items)) return [];
const results: NeshanSearchResult[] = [];
for (const raw of data.items) {
const item = raw as { title?: string; address?: string; location?: { x?: number; y?: number } };
if (typeof item.location?.x !== 'number' || typeof item.location?.y !== 'number') continue;
results.push({
title: item.title ?? '',
address: item.address ?? '',
location: { latitude: item.location.y, longitude: item.location.x },
});
}
return results;
}
/** Reverse-geocodes a pin to a human-readable address — `null` on any failure/unset key. */
export async function reverseGeocodeNeshan(point: LatLng): Promise<string | null> {
const url = `${REVERSE_URL}?lat=${point.latitude}&lng=${point.longitude}`;
const data = (await neshanGet(url)) as { formatted_address?: string } | null;
return typeof data?.formatted_address === 'string' && data.formatted_address.length > 0 ? data.formatted_address : null;
}
@@ -48,12 +48,30 @@ function defaultFamilyRecord(patientId: number): FamilyCareRecord {
return {
patientId,
medications: [
{ id: 'm1', name: 'متفورمین ۵۰۰', dosage: '۱ قرص', frequency: 'روزی دو بار', timingNote: 'صبح و شب، بعد از غذا' },
{ id: 'm2', name: 'لوزارتان ۲۵', dosage: '۱ قرص', frequency: 'روزی یک بار', timingNote: 'صبح' },
{
id: 'm1',
name: 'متفورمین ۵۰۰',
doseAmount: '۱',
doseUnit: 'tablet',
frequencyCode: 'twice_daily',
frequencyText: null,
timeOfDay: ['morning', 'night'],
timingNote: 'بعد از غذا',
},
{
id: 'm2',
name: 'لوزارتان ۲۵',
doseAmount: '۱',
doseUnit: 'tablet',
frequencyCode: 'once_daily',
frequencyText: null,
timeOfDay: ['morning'],
timingNote: null,
},
],
routine: [
{ id: 'r1', label: 'اندازه‌گیری فشار خون', timeOfDay: 'صبح', note: 'پیش از داروی فشار' },
{ id: 'r2', label: 'پیاده‌روی کوتاه', timeOfDay: 'عصر', note: null },
{ id: 'r1', label: 'اندازه‌گیری فشار خون', timeOfDay: ['morning'], note: 'پیش از داروی فشار' },
{ id: 'r2', label: 'پیاده‌روی کوتاه', timeOfDay: ['evening'], note: null },
],
tasks: [
{ id: 't1', label: 'دادن متفورمین', done: false },
+28 -3
View File
@@ -11,6 +11,9 @@ import type { PageParams, Paginated } from '@/lib/api/types';
* 2. **The family-owned editable record** (داروها/روتین/وظایف — medications/routine/tasks) — **NO backend
* exists** (neither the b14 contract nor the data model has it; **REQ-027**). The customer maintains it;
* it is mocked behind this seam. The domain is therefore **mock-primary** (see `constants.ts`).
* ui-phase-9 added the structured shape (dose amount/unit, frequency preset codes, time-of-day codes)
* that replaced free-text dose/frequency/routine-time editing — recorded as a REQ-027 addendum, not a
* new REQ number, in `requests/for-backend.md`.
*
* Load-bearing rules (contract + phase §5):
* - **Family-owned & patient-scoped.** The customer owns/edits medications/routine/tasks; the record
@@ -31,12 +34,34 @@ export const CARE_RECORD_TABS: readonly CareRecordTab[] = ['medications', 'routi
// ── Family-owned editable record (REQ-027 — customer-maintained, no backend) ──────────────────────────────
/** Structured dose unit codes (ui-phase-9 REQ-027 addendum) — stable codes, never a raw label on the wire. */
export type DoseUnit = 'tablet' | 'capsule' | 'drop' | 'cc' | 'unit';
export const DOSE_UNITS: readonly DoseUnit[] = ['tablet', 'capsule', 'drop', 'cc', 'unit'] as const;
/** Frequency presets (ui-phase-9 REQ-027 addendum); `null` + `frequencyText` is the free-text fallback. */
export type FrequencyPreset = 'once_daily' | 'twice_daily' | 'three_times_daily' | 'every_8_hours' | 'as_needed';
export const FREQUENCY_PRESETS: readonly FrequencyPreset[] = [
'once_daily',
'twice_daily',
'three_times_daily',
'every_8_hours',
'as_needed',
] as const;
/** Time-of-day chip codes (ui-phase-9 REQ-027 addendum) — shared by medications and routine items. */
export type TimeOfDayCode = 'morning' | 'noon' | 'evening' | 'night';
export const TIME_OF_DAY_CODES: readonly TimeOfDayCode[] = ['morning', 'noon', 'evening', 'night'] as const;
/** A medication the family tracks. `id` is a stable client id (the record has no server identity yet). */
export interface Medication {
id: string;
name: string;
dosage: string | null;
frequency: string;
doseAmount: string | null;
doseUnit: DoseUnit | null;
frequencyCode: FrequencyPreset | null;
/** Free-text fallback — used when no preset fits (`frequencyCode` is `null`). */
frequencyText: string | null;
timeOfDay: TimeOfDayCode[];
timingNote: string | null;
}
@@ -44,7 +69,7 @@ export interface Medication {
export interface RoutineItem {
id: string;
label: string;
timeOfDay: string | null;
timeOfDay: TimeOfDayCode[];
note: string | null;
}