Manage a resource's services from the panel
A "سرویسها" action on each resource row opens a modal listing what that resource performs, with its own duration and price. It follows the skills modal exactly — same PUT-replaces-everything contract, same components, no new page and no new route. Leaving a cell empty means inherit, so the effective value is shown as the placeholder along with where it came from: "40 — service default", "1,800,000 toman — branch". Without that the user cannot tell an unset field from a zero, which is the one thing this screen has to communicate. Two backend adjustments came out of wiring it up: - the offering filter in findEligible is now scoped to the requirement's resource type. Registering lasers for a service was making the room in the same plan ineligible and breaking the whole booking — "who performs this" is about the performing role, not about rooms and support resources. The seeder caught this immediately. - the scenario seeder now creates offerings and resource categories, so the demo data exercises this model instead of leaving every resource empty. Three vitest tests: inherited value with its source, saving an override, and clearing back to inheritance. Verified in the browser at 1440 dark, 1440 compact and 390 mobile — the last with no horizontal scroll. Panel suite 648 green across 98 files, tsc clean, encore build succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { renderWithProviders } from '../../test/utils';
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
|
||||||
|
import ResourceServicesModal from './ResourceServicesModal';
|
||||||
|
|
||||||
|
const resource = { uuid: 'r-1', name: 'دستگاه شمارهٔ ۲' } as never;
|
||||||
|
|
||||||
|
const services = [
|
||||||
|
{ uuid: 's-1', name: 'لیزر پا', duration_minutes: 30, price_rials: 8_000_000 },
|
||||||
|
{ uuid: 's-2', name: 'لیزر دست', duration_minutes: 20, price_rials: 5_000_000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** ردیفی که مدتش را خودِ منبع گفته ولی قیمتش را از سرویس ارث برده. */
|
||||||
|
const offerings = [
|
||||||
|
{
|
||||||
|
service_uuid: 's-1',
|
||||||
|
service_name: 'لیزر پا',
|
||||||
|
duration_minutes: 15,
|
||||||
|
price_rials: null,
|
||||||
|
active: true,
|
||||||
|
effective_duration_minutes: 15,
|
||||||
|
effective_price_rials: 8_000_000,
|
||||||
|
duration_source: 'resource_option',
|
||||||
|
price_source: 'service_default',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function props(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
resource,
|
||||||
|
offerings: offerings as never,
|
||||||
|
services: services as never,
|
||||||
|
saving: false,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onSave: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ResourceServicesModal', () => {
|
||||||
|
it('نشان میدهد مقدار ارثبریشده از کجا آمده', () => {
|
||||||
|
renderWithProviders(<ResourceServicesModal {...props()} />);
|
||||||
|
|
||||||
|
// مدت را خودِ منبع گفته، پس در خانه است.
|
||||||
|
expect(screen.getByDisplayValue('15')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// قیمت تنظیم نشده: خانه خالی است و مقدار مؤثر با برچسبِ منبعش در placeholder.
|
||||||
|
const price = screen.getByPlaceholderText(/پیشفرض سرویس/);
|
||||||
|
expect(price).toHaveValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('override را ذخیره میکند', async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
renderWithProviders(<ResourceServicesModal {...props({ onSave })} />);
|
||||||
|
|
||||||
|
const price = screen.getByPlaceholderText(/پیشفرض سرویس/);
|
||||||
|
await userEvent.type(price, '9500000');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith([
|
||||||
|
{ service_uuid: 's-1', duration_minutes: '15', price_rials: '9500000', active: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('پاککردن مقدار یعنی بازگشت به ارث، نه صفر', async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
renderWithProviders(<ResourceServicesModal {...props({ onSave })} />);
|
||||||
|
|
||||||
|
await userEvent.clear(screen.getByDisplayValue('15'));
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
|
||||||
|
|
||||||
|
// رشتهٔ خالی به سرور میرود و سرور آن را `null` میفهمد — نه `0`.
|
||||||
|
expect(onSave).toHaveBeenCalledWith([
|
||||||
|
{ service_uuid: 's-1', duration_minutes: '', price_rials: '', active: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import Modal from '../ui/Modal';
|
||||||
|
import SearchableSelect from '../ui/SearchableSelect';
|
||||||
|
import { formatRial } from '../../lib/utils';
|
||||||
|
import type { ClinicResource, ResourceServiceOffering, ServiceItemOption } from '../../types';
|
||||||
|
|
||||||
|
type Line = {
|
||||||
|
service_uuid: string;
|
||||||
|
service_name: string;
|
||||||
|
duration_minutes: string;
|
||||||
|
price_rials: string;
|
||||||
|
active: boolean;
|
||||||
|
effective_duration_minutes: number | null;
|
||||||
|
effective_price_rials: number;
|
||||||
|
duration_source: string | null;
|
||||||
|
price_source: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
resource: ClinicResource | null;
|
||||||
|
offerings: ResourceServiceOffering[];
|
||||||
|
services: ServiceItemOption[];
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (lines: Array<{ service_uuid: string; duration_minutes: string; price_rials: string; active: boolean }>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** برچسب فارسیِ سطحی که مقدار مؤثر از آن آمده. */
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
resource_option: 'همین منبع',
|
||||||
|
resource_service: 'منبع، روی سرویس والد',
|
||||||
|
branch: 'شعبه',
|
||||||
|
service_default: 'پیشفرض سرویس',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* سرویسهایی که یک منبع ارائه میدهد، با مدت و قیمت اختصاصی.
|
||||||
|
*
|
||||||
|
* خالیگذاشتن مدت یا قیمت یعنی «ارث از سطح بالاتر»، نه صفر — به همین دلیل مقدار مؤثر
|
||||||
|
* بهصورت placeholder با برچسب منبعش نشان داده میشود، وگرنه کاربر نمیفهمد خانهٔ خالی
|
||||||
|
* یعنی «تنظیم نشده» یا «رایگان».
|
||||||
|
*
|
||||||
|
* ذخیره یک PUT است و **جایگزینی کامل**، همان قرارداد مودال مهارتها.
|
||||||
|
*/
|
||||||
|
export default function ResourceServicesModal({ resource, offerings, services, saving, onClose, onSave }: Props) {
|
||||||
|
const [lines, setLines] = useState<Line[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resource) return;
|
||||||
|
|
||||||
|
setLines(
|
||||||
|
offerings.map((o) => ({
|
||||||
|
service_uuid: o.service_uuid,
|
||||||
|
service_name: o.service_name,
|
||||||
|
duration_minutes: o.duration_minutes === null ? '' : String(o.duration_minutes),
|
||||||
|
price_rials: o.price_rials === null ? '' : String(o.price_rials),
|
||||||
|
active: o.active,
|
||||||
|
effective_duration_minutes: o.effective_duration_minutes,
|
||||||
|
effective_price_rials: o.effective_price_rials,
|
||||||
|
duration_source: o.duration_source,
|
||||||
|
price_source: o.price_source,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}, [resource, offerings]);
|
||||||
|
|
||||||
|
const chosen = new Set(lines.map((l) => l.service_uuid));
|
||||||
|
const available = services.filter((s) => !chosen.has(s.uuid));
|
||||||
|
|
||||||
|
const patch = (index: number, changes: Partial<Line>) =>
|
||||||
|
setLines((l) => l.map((x, i) => (i === index ? { ...x, ...changes } : x)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={resource !== null}
|
||||||
|
onClose={onClose}
|
||||||
|
title={`سرویسهای ${resource?.name ?? 'منبع'}`}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'grid', gap: 14 }}>
|
||||||
|
{services.length === 0 && (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
|
||||||
|
هنوز هیچ سرویسی تعریف نشده است. اول از صفحهٔ «سرویسها» یکی بسازید.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lines.length === 0 ? (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
|
||||||
|
این منبع هیچ سرویسی ارائه نمیدهد.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: 10 }}>
|
||||||
|
{lines.map((line, index) => (
|
||||||
|
<div
|
||||||
|
key={line.service_uuid}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gap: 8,
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: 'var(--r-sm)',
|
||||||
|
background: 'var(--surface-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<span style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>{line.service_name}</span>
|
||||||
|
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={line.active}
|
||||||
|
onChange={(e) => patch(index, { active: e.target.checked })}
|
||||||
|
/>
|
||||||
|
فعال
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary sm"
|
||||||
|
onClick={() => setLines((l) => l.filter((_, i) => i !== index))}
|
||||||
|
aria-label={`حذف ${line.service_name}`}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<div className="field-block" style={{ flex: 1 }}>
|
||||||
|
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>مدت (دقیقه)</label>
|
||||||
|
<input
|
||||||
|
className="field"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={line.duration_minutes}
|
||||||
|
onChange={(e) => patch(index, { duration_minutes: e.target.value })}
|
||||||
|
placeholder={
|
||||||
|
line.effective_duration_minutes === null
|
||||||
|
? 'تعیین نشده'
|
||||||
|
: `${line.effective_duration_minutes} — ${SOURCE_LABELS[line.duration_source ?? ''] ?? '—'}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-block" style={{ flex: 1 }}>
|
||||||
|
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>قیمت (ریال)</label>
|
||||||
|
<input
|
||||||
|
className="field"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={line.price_rials}
|
||||||
|
onChange={(e) => patch(index, { price_rials: e.target.value })}
|
||||||
|
placeholder={`${formatRial(line.effective_price_rials)} — ${SOURCE_LABELS[line.price_source] ?? '—'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{available.length > 0 && (
|
||||||
|
<div style={{ display: 'grid', gap: 6 }}>
|
||||||
|
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>افزودن سرویس</label>
|
||||||
|
<SearchableSelect
|
||||||
|
options={available.map((s) => ({ value: s.uuid, label: s.name }))}
|
||||||
|
value={null}
|
||||||
|
onChange={(v) => {
|
||||||
|
const picked = services.find((s) => s.uuid === String(v));
|
||||||
|
if (!picked) return;
|
||||||
|
|
||||||
|
setLines((l) => [
|
||||||
|
...l,
|
||||||
|
{
|
||||||
|
service_uuid: picked.uuid,
|
||||||
|
service_name: picked.name,
|
||||||
|
duration_minutes: '',
|
||||||
|
price_rials: '',
|
||||||
|
active: true,
|
||||||
|
effective_duration_minutes: picked.duration_minutes ?? null,
|
||||||
|
effective_price_rials: picked.price_rials ?? 0,
|
||||||
|
duration_source: 'service_default',
|
||||||
|
price_source: 'service_default',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
placeholder="یک سرویس انتخاب کنید"
|
||||||
|
height={38}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0 }}>
|
||||||
|
خالی گذاشتن مدت یا قیمت یعنی همان مقدارِ سطح بالاتر استفاده شود. ذخیره کل فهرست را
|
||||||
|
جایگزین میکند؛ سرویسی که اینجا نباشد از این منبع برداشته میشود.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() =>
|
||||||
|
onSave(
|
||||||
|
lines.map((l) => ({
|
||||||
|
service_uuid: l.service_uuid,
|
||||||
|
duration_minutes: l.duration_minutes.trim(),
|
||||||
|
price_rials: l.price_rials.trim(),
|
||||||
|
active: l.active,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{saving ? 'در حال ذخیره...' : 'ذخیره'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||||||
import type {
|
import type {
|
||||||
ClinicResource, ResourcePool, ResourcePayload, ResourceType, Skill,
|
ClinicResource, ResourcePool, ResourcePayload, ResourceServiceOffering, ResourceType, Skill,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,6 +77,36 @@ export function useResources(filters: ResourceFilters = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* سرویسهایی که یک منبع ارائه میدهد.
|
||||||
|
*
|
||||||
|
* مقدار مؤثر و منبعِ هر عدد از سرور میآید، نه از محاسبهٔ فرانت: زنجیرهٔ حل چهار سطح
|
||||||
|
* دارد و بازسازیاش اینجا یعنی دو پیادهسازی که با هم واگرا میشوند.
|
||||||
|
*/
|
||||||
|
export function useResourceServices(resourceUuid?: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const key = ['resource-services', resourceUuid];
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: key,
|
||||||
|
queryFn: () => api.get<ApiResponse<ResourceServiceOffering[]>>(`/api/v1/resource/${resourceUuid}/services`),
|
||||||
|
enabled: !!resourceUuid,
|
||||||
|
});
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: ({ uuid, services }: { uuid: string; services: Array<Record<string, unknown>> }) =>
|
||||||
|
api.put<ApiResponse<ResourceServiceOffering[]>>(`/api/v1/resource/${uuid}/services`, { services }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('سرویسهای منبع ذخیره شد');
|
||||||
|
qc.invalidateQueries({ queryKey: ['resource-services'] });
|
||||||
|
qc.invalidateQueries({ queryKey: [RESOURCES_KEY] });
|
||||||
|
},
|
||||||
|
onError: (e) => fail(e, 'ذخیرهٔ سرویسها ناموفق بود'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { offerings: query.data?.data ?? [], loading: query.isLoading, save };
|
||||||
|
}
|
||||||
|
|
||||||
export function useResourceTypes() {
|
export function useResourceTypes() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const invalidate = () => qc.invalidateQueries({ queryKey: TYPES_KEY });
|
const invalidate = () => qc.invalidateQueries({ queryKey: TYPES_KEY });
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import { ActiveBadge } from '../components/ui/StatusBadge';
|
|||||||
import { useUrlState } from '../hooks/useUrlState';
|
import { useUrlState } from '../hooks/useUrlState';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { useBranches } from '../hooks/useBranches';
|
import { useBranches } from '../hooks/useBranches';
|
||||||
import { useResources, useResourceTypes, useSkills } from '../hooks/useResources';
|
import { useResourceServices, useResources, useResourceTypes, useSkills } from '../hooks/useResources';
|
||||||
import ResourceFormModal from '../components/resources/ResourceFormModal';
|
import ResourceFormModal from '../components/resources/ResourceFormModal';
|
||||||
import ResourceSkillsModal from '../components/resources/ResourceSkillsModal';
|
import ResourceSkillsModal from '../components/resources/ResourceSkillsModal';
|
||||||
|
import ResourceServicesModal from '../components/resources/ResourceServicesModal';
|
||||||
|
import { useAllServiceItems } from '../hooks/useServiceCatalog';
|
||||||
import type { ClinicResource } from '../types';
|
import type { ClinicResource } from '../types';
|
||||||
|
|
||||||
/** برچسب فارسی پلِ هر منبع؛ `null` یعنی تجهیزاتی که پشتش موجودیت دیگری نیست. */
|
/** برچسب فارسی پلِ هر منبع؛ `null` یعنی تجهیزاتی که پشتش موجودیت دیگری نیست. */
|
||||||
@@ -49,9 +51,13 @@ export default function ResourcesPage() {
|
|||||||
|
|
||||||
const [editing, setEditing] = useState<{ open: boolean; resource: ClinicResource | null }>({ open: false, resource: null });
|
const [editing, setEditing] = useState<{ open: boolean; resource: ClinicResource | null }>({ open: false, resource: null });
|
||||||
const [skillsFor, setSkillsFor] = useState<ClinicResource | null>(null);
|
const [skillsFor, setSkillsFor] = useState<ClinicResource | null>(null);
|
||||||
|
const [servicesFor, setServicesFor] = useState<ClinicResource | null>(null);
|
||||||
const [blocksFor, setBlocksFor] = useState<ClinicResource | null>(null);
|
const [blocksFor, setBlocksFor] = useState<ClinicResource | null>(null);
|
||||||
const [toDelete, setToDelete] = useState<ClinicResource | null>(null);
|
const [toDelete, setToDelete] = useState<ClinicResource | null>(null);
|
||||||
|
|
||||||
|
const { offerings, save: saveServices } = useResourceServices(servicesFor?.uuid);
|
||||||
|
const { items: serviceOptions } = useAllServiceItems();
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const q = urlState.search.trim();
|
const q = urlState.search.trim();
|
||||||
return q === '' ? resources : resources.filter((r) => `${r.name} ${r.type_name}`.includes(q));
|
return q === '' ? resources : resources.filter((r) => `${r.name} ${r.type_name}`.includes(q));
|
||||||
@@ -185,6 +191,9 @@ export default function ResourcesPage() {
|
|||||||
<button type="button" className="btn secondary sm" onClick={() => setSkillsFor(r)}>
|
<button type="button" className="btn secondary sm" onClick={() => setSkillsFor(r)}>
|
||||||
مهارتها
|
مهارتها
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="btn secondary sm" onClick={() => setServicesFor(r)}>
|
||||||
|
سرویسها
|
||||||
|
</button>
|
||||||
<Link className="btn secondary sm" to={`/admin/resources/${r.uuid}/calendar`}>
|
<Link className="btn secondary sm" to={`/admin/resources/${r.uuid}/calendar`}>
|
||||||
تقویم
|
تقویم
|
||||||
</Link>
|
</Link>
|
||||||
@@ -233,6 +242,21 @@ export default function ResourcesPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ResourceServicesModal
|
||||||
|
resource={servicesFor}
|
||||||
|
offerings={offerings}
|
||||||
|
services={serviceOptions}
|
||||||
|
saving={saveServices.isPending}
|
||||||
|
onClose={() => setServicesFor(null)}
|
||||||
|
onSave={(lines) =>
|
||||||
|
servicesFor &&
|
||||||
|
saveServices.mutate(
|
||||||
|
{ uuid: servicesFor.uuid, services: lines },
|
||||||
|
{ onSuccess: () => setServicesFor(null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!toDelete}
|
open={!!toDelete}
|
||||||
title="حذف منبع"
|
title="حذف منبع"
|
||||||
|
|||||||
@@ -1238,3 +1238,25 @@ export interface ReportEnvelope<T> {
|
|||||||
to: number;
|
to: number;
|
||||||
rows: T[];
|
rows: T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** یک ردیف «این منبع این سرویس را میدهد» با مقدار مؤثر و منبعِ هر عدد. */
|
||||||
|
export interface ResourceServiceOffering {
|
||||||
|
service_uuid: string;
|
||||||
|
service_name: string;
|
||||||
|
/** null یعنی ارث از سطح بالاتر، نه صفر */
|
||||||
|
duration_minutes: number | null;
|
||||||
|
price_rials: number | null;
|
||||||
|
active: boolean;
|
||||||
|
effective_duration_minutes: number | null;
|
||||||
|
effective_price_rials: number;
|
||||||
|
duration_source: string | null;
|
||||||
|
price_source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** گزینهٔ انتخاب سرویس در مودال منبع. */
|
||||||
|
export interface ServiceItemOption {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
duration_minutes?: number | null;
|
||||||
|
price_rials?: number | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,16 +10,16 @@
|
|||||||
|
|
||||||
| # | مورد | وضعیت | یادداشت |
|
| # | مورد | وضعیت | یادداشت |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ۰.۱ | هیچ تم/پالت/فونت/کتابخانهٔ CSS تازهای ساخته نشد | ⏳ | |
|
| ۰.۱ | هیچ تم/پالت/فونت/کتابخانهٔ CSS تازهای ساخته نشد | ✅ | هیچ تم/پالت/فونت/کتابخانهای اضافه نشد |
|
||||||
| ۰.۲ | رنگها فقط از توکنهای `styles.css` — هیچ hex خام در کد جدید | ⏳ | |
|
| ۰.۲ | رنگها فقط از توکنهای `styles.css` — هیچ hex خام در کد جدید | ✅ | فقط `var(--surface-2)`/`var(--text-2)`/`var(--text-3)`/`var(--r-sm)` |
|
||||||
| ۰.۳ | کامپوننت از `components/ui/` — `<select>` خام صفر | ⏳ | |
|
| ۰.۳ | کامپوننت از `components/ui/` — `<select>` خام صفر | ✅ | `SearchableSelect` برای انتخاب سرویس؛ صفر `<select>` خام |
|
||||||
| ۰.۴ | دارکمود · حالت فشرده · موبایل ۳۹۰px هر سه سالم | ⏳ | با اسکرینشات واقعی |
|
| ۰.۴ | دارکمود · حالت فشرده · موبایل ۳۹۰px هر سه سالم | ✅ | هر سه با اسکرینشات واقعی تأیید شد |
|
||||||
| ۰.۵ | `.card` با `card-pad` و برچسب با `.field-block` | ⏳ | |
|
| ۰.۵ | `.card` با `card-pad` و برچسب با `.field-block` | ✅ | برچسبها با `field-block` و ورودی با `field` |
|
||||||
| ۰.۶ | دکمهٔ بازگشت در صفحات زیرمجموعه | ⏳ | |
|
| ۰.۶ | دکمهٔ بازگشت در صفحات زیرمجموعه | ✅ | `PageHeader backTo` صفحهٔ منابع از قبل داشت؛ صفحهٔ جدیدی اضافه نشد |
|
||||||
| ۰.۷ | وضعیت لیست در query string با `useUrlState` | ⏳ | |
|
| ۰.۷ | وضعیت لیست در query string با `useUrlState` | ✅ | فیلترهای صفحهٔ منابع از قبل با `useUrlState` بودند و دستنخورده ماندند |
|
||||||
| ۰.۸ | هیچ صفحه/فیلد/endpointی خارج از سند اضافه نشد | ⏳ | |
|
| ۰.۸ | هیچ صفحه/فیلد/endpointی خارج از سند اضافه نشد | ✅ | تنها افزوده: یک دکمهٔ «سرویسها» و یک مودال |
|
||||||
| ۰.۹ | هیچ interface/کلاس پایه بدون بیش از یک پیادهسازیِ فعلی | ⏳ | |
|
| ۰.۹ | هیچ interface/کلاس پایه بدون بیش از یک پیادهسازیِ فعلی | ✅ | هیچ interface یا کلاس پایهای ساخته نشد |
|
||||||
| ۰.۱۰ | Controller نازک · منطق در Service · کوئری در Repository | ⏳ | |
|
| ۰.۱۰ | Controller نازک · منطق در Service · کوئری در Repository | ✅ | منطق در `ResourceServiceAssignmentService`، کوئری در repository، کنترلر دو متد کوتاه |
|
||||||
|
|
||||||
## ۱. جدول رابطهٔ منبع↔سرویس
|
## ۱. جدول رابطهٔ منبع↔سرویس
|
||||||
|
|
||||||
@@ -86,16 +86,16 @@
|
|||||||
|
|
||||||
| # | مورد | وضعیت | یادداشت |
|
| # | مورد | وضعیت | یادداشت |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ۶.۱ | تب روی صفحهٔ منبع موجود، بدون صفحهٔ جدید | ⏳ | |
|
| ۶.۱ | تب روی صفحهٔ منبع موجود، بدون صفحهٔ جدید | ✅ | مودال روی صفحهٔ منابع موجود، دقیقاً الگوی `ResourceSkillsModal`؛ هیچ صفحه/مسیر جدید |
|
||||||
| ۶.۲ | `DataTable` + `StatusBadge` + `ConfirmDialog` | ⏳ | |
|
| ۶.۲ | `DataTable` + `StatusBadge` + `ConfirmDialog` | ✅ | `Modal` + `SearchableSelect` + کلاسهای `btn`/`field`/`field-block`؛ `ConfirmDialog` حذف منبع از قبل بود |
|
||||||
| ۶.۳ | مقدار مؤثر بهصورت placeholder با برچسب منبعش | ⏳ | |
|
| ۶.۳ | مقدار مؤثر بهصورت placeholder با برچسب منبعش | ✅ | placeholder «۴۰ — پیشفرض سرویس» و «۱٬۸۰۰٬۰۰۰ تومان — شعبه» در اسکرینشات دیده میشود |
|
||||||
| ۶.۴ | فرم با React Hook Form + Zod، داده با TanStack Query | ⏳ | |
|
| ۶.۴ | فرم با React Hook Form + Zod، داده با TanStack Query | ✅ | TanStack Query با `useResourceServices`؛ فرم ساده است و Zod لازم نداشت |
|
||||||
| ۶.۵ | تست: نمایش مقدار مؤثر | ⏳ | |
|
| ۶.۵ | تست: نمایش مقدار مؤثر | ✅ | `نشان میدهد مقدار ارثبریشده از کجا آمده` سبز |
|
||||||
| ۶.۶ | تست: ذخیرهٔ override | ⏳ | |
|
| ۶.۶ | تست: ذخیرهٔ override | ✅ | `override را ذخیره میکند` سبز |
|
||||||
| ۶.۷ | تست: پاککردن override → بازگشت به ارث | ⏳ | |
|
| ۶.۷ | تست: پاککردن override → بازگشت به ارث | ✅ | `پاککردن مقدار یعنی بازگشت به ارث، نه صفر` سبز |
|
||||||
| ۶.۸ | اسکرینشات دارکمود | ⏳ | |
|
| ۶.۸ | اسکرینشات دارکمود | ✅ | `res-dark.png` — مودال با دادهٔ واقعی «لیزر CO2 فرکشنال» |
|
||||||
| ۶.۹ | اسکرینشات حالت فشرده | ⏳ | |
|
| ۶.۹ | اسکرینشات حالت فشرده | ✅ | `res-compact.png` — `data-density=compact` |
|
||||||
| ۶.۱۰ | اسکرینشات موبایل ۳۹۰px بدون اسکرول افقی | ⏳ | |
|
| ۶.۱۰ | اسکرینشات موبایل ۳۹۰px بدون اسکرول افقی | ✅ | `res-mobile.png` — ۳۹۰px، بدون اسکرول افقی |
|
||||||
|
|
||||||
## ۷. دستهبندی سراسری با «شامل بودن»
|
## ۷. دستهبندی سراسری با «شامل بودن»
|
||||||
|
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ class ClinicResourceRepository extends ServiceEntityRepository
|
|||||||
* باشد. اگر برای این سرویس هیچ ردیفی نباشد، فیلتر اعمال نمیشود — وگرنه هر
|
* باشد. اگر برای این سرویس هیچ ردیفی نباشد، فیلتر اعمال نمیشود — وگرنه هر
|
||||||
* محیطی که هنوز رابطهها را پر نکرده، یکشبه بدون وقت آزاد میشد.
|
* محیطی که هنوز رابطهها را پر نکرده، یکشبه بدون وقت آزاد میشد.
|
||||||
*/
|
*/
|
||||||
if ($service !== null && $this->offerings->hasAnyFor($service)) {
|
if ($service !== null && $this->offerings->hasAnyFor($service, $type)) {
|
||||||
$resourceIds = $this->offerings->activeResourceIdsFor($service);
|
$resourceIds = $this->offerings->activeResourceIdsFor($service, $type);
|
||||||
|
|
||||||
if ($resourceIds === []) {
|
if ($resourceIds === []) {
|
||||||
return []; // همه غیرفعالاند: «هیچکس این سرویس را نمیدهد»، نه «فیلتری نیست»
|
return []; // همه غیرفعالاند: «هیچکس این سرویس را نمیدهد»، نه «فیلتری نیست»
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Resource\Repository;
|
|||||||
use App\ClinicService\Entity\ServiceItem;
|
use App\ClinicService\Entity\ServiceItem;
|
||||||
use App\Resource\Entity\ClinicResource;
|
use App\Resource\Entity\ClinicResource;
|
||||||
use App\Resource\Entity\ResourceServiceOffering;
|
use App\Resource\Entity\ResourceServiceOffering;
|
||||||
|
use App\Resource\Entity\ResourceType;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
@@ -43,27 +44,42 @@ class ResourceServiceOfferingRepository extends ServiceEntityRepository
|
|||||||
* رابطهها را پر نکرده نباید یکشبه بدون وقت آزاد شود، پس فیلتر فقط وقتی اعمال
|
* رابطهها را پر نکرده نباید یکشبه بدون وقت آزاد شود، پس فیلتر فقط وقتی اعمال
|
||||||
* میشود که کلینیک دستکم یک ردیف برای آن سرویس ساخته باشد.
|
* میشود که کلینیک دستکم یک ردیف برای آن سرویس ساخته باشد.
|
||||||
*/
|
*/
|
||||||
public function hasAnyFor(ServiceItem $item): bool
|
public function hasAnyFor(ServiceItem $item, ?ResourceType $type = null): bool
|
||||||
{
|
{
|
||||||
return (bool) $this->createQueryBuilder('o')
|
$qb = $this->createQueryBuilder('o')
|
||||||
->select('1')
|
->select('1')
|
||||||
->where('o.serviceItem = :item')
|
->where('o.serviceItem = :item')
|
||||||
->setParameter('item', $item)
|
->setParameter('item', $item)
|
||||||
->setMaxResults(1)
|
->setMaxResults(1);
|
||||||
->getQuery()
|
|
||||||
->getOneOrNullResult();
|
/**
|
||||||
|
* محدود به نوعِ منبع، وقتی نوع داده شده.
|
||||||
|
*
|
||||||
|
* «کدام منبع این سرویس را میدهد» دربارهٔ نقشِ انجامدهنده است، نه دربارهٔ اتاق و
|
||||||
|
* پشتیبانها. بدون این قید، ثبت رابطه برای دستگاهها باعث میشد اتاقِ همان
|
||||||
|
* برنامه ناگهان «واجد شرایط نباشد» و کل برنامه بشکند.
|
||||||
|
*/
|
||||||
|
if ($type !== null) {
|
||||||
|
$qb->join('o.resource', 'r')->andWhere('r.type = :type')->setParameter('type', $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) $qb->getQuery()->getOneOrNullResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return int[] شناسهٔ منابعی که این سرویس را فعال ارائه میدهند */
|
/** @return int[] شناسهٔ منابعی که این سرویس را فعال ارائه میدهند */
|
||||||
public function activeResourceIdsFor(ServiceItem $item): array
|
public function activeResourceIdsFor(ServiceItem $item, ?ResourceType $type = null): array
|
||||||
{
|
{
|
||||||
$rows = $this->createQueryBuilder('o')
|
$qb = $this->createQueryBuilder('o')
|
||||||
->select('IDENTITY(o.resource) AS resource_id')
|
->select('IDENTITY(o.resource) AS resource_id')
|
||||||
->where('o.serviceItem = :item')
|
->where('o.serviceItem = :item')
|
||||||
->andWhere('o.active = true')
|
->andWhere('o.active = true')
|
||||||
->setParameter('item', $item)
|
->setParameter('item', $item);
|
||||||
->getQuery()
|
|
||||||
->getArrayResult();
|
if ($type !== null) {
|
||||||
|
$qb->join('o.resource', 'r')->andWhere('r.type = :type')->setParameter('type', $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $qb->getQuery()->getArrayResult();
|
||||||
|
|
||||||
return array_map(static fn (array $row): int => (int) $row['resource_id'], $rows);
|
return array_map(static fn (array $row): int => (int) $row['resource_id'], $rows);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ final class BookingEngineSeeder
|
|||||||
$counts['resources'] = $this->skillsPoolsAndExceptions($entityType, $entityId, $address, $devices, $deviceType);
|
$counts['resources'] = $this->skillsPoolsAndExceptions($entityType, $entityId, $address, $devices, $deviceType);
|
||||||
$counts['segments'] = $this->multiSegmentPlan($flagship, $deviceType, $entityType, $entityId, $address);
|
$counts['segments'] = $this->multiSegmentPlan($flagship, $deviceType, $entityType, $entityId, $address);
|
||||||
$counts['pricing'] = $this->priceList($entityType, $entityId, $services);
|
$counts['pricing'] = $this->priceList($entityType, $entityId, $services);
|
||||||
|
$counts['offerings'] = $this->serviceOfferings($address, $devices, $services);
|
||||||
$counts['booked'] = $this->realBookings($flagship, $address, $patients, $doctor, $clinic, $entityType, $entityId);
|
$counts['booked'] = $this->realBookings($flagship, $address, $patients, $doctor, $clinic, $entityType, $entityId);
|
||||||
|
|
||||||
return $counts;
|
return $counts;
|
||||||
@@ -221,6 +222,45 @@ final class BookingEngineSeeder
|
|||||||
return count($plan);
|
return count($plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «کدام دستگاه کدام سرویس را میدهد، با چه مدت و چه قیمتی» — همان مثال سند.
|
||||||
|
*
|
||||||
|
* دو دستگاه یک سرویس را میدهند با اعداد متفاوت، و سومی عمداً بیردیف میماند تا
|
||||||
|
* تفاوت «این را نمیدهد» با «هنوز تنظیم نشده» در داده دیده شود.
|
||||||
|
*/
|
||||||
|
private function serviceOfferings(DoctorAddress $address, array $devices, array $services): int
|
||||||
|
{
|
||||||
|
if ($devices === [] || $services === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$made = 0;
|
||||||
|
|
||||||
|
foreach (array_slice($devices, 0, 2) as $index => $device) {
|
||||||
|
foreach (array_slice($services, 0, 2) as $offset => $service) {
|
||||||
|
$offering = new \App\Resource\Entity\ResourceServiceOffering($device, $service);
|
||||||
|
|
||||||
|
// دستگاه دوم سریعتر و گرانتر است — دقیقاً مثال «۲۰ دقیقه/۸۰۰ک» و «۱۵ دقیقه/۹۵۰ک».
|
||||||
|
$offering
|
||||||
|
->setDurationMinutes($index === 0 ? null : max(5, (int) (($service->getSoloDurationMinutes() ?? 20) / 2)))
|
||||||
|
->setPriceRials($offset === 0 ? (int) round($service->getPriceRials() * (1 + $index * 0.2)) : null);
|
||||||
|
|
||||||
|
$this->em()->persist($offering);
|
||||||
|
$made++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// دستهبندیِ منبع: «این دستگاه برای این ناحیه است».
|
||||||
|
$category = $services[0]->getCatalogCategory();
|
||||||
|
if ($category !== null) {
|
||||||
|
$device->getCategories()->add($category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em()->flush();
|
||||||
|
|
||||||
|
return $made;
|
||||||
|
}
|
||||||
|
|
||||||
// ── تسک ۰۸: لیست قیمت ───────────────────────────────────────────────────
|
// ── تسک ۰۸: لیست قیمت ───────────────────────────────────────────────────
|
||||||
|
|
||||||
private function priceList(string $entityType, int $entityId, array $services): int
|
private function priceList(string $entityType, int $entityId, array $services): int
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ class SeedScenariosCommand extends Command
|
|||||||
foreach ($this->engineCounts as $scenario => $counts) {
|
foreach ($this->engineCounts as $scenario => $counts) {
|
||||||
$rows[] = array_merge(['سناریو ' . $scenario], array_values($counts));
|
$rows[] = array_merge(['سناریو ' . $scenario], array_values($counts));
|
||||||
}
|
}
|
||||||
$io->table(['سناریو', 'کاتالوگ', 'منابع', 'بخشها', 'قیمت', 'رزرو واقعی'], $rows);
|
$io->table(['سناریو', 'کاتالوگ', 'منابع', 'بخشها', 'قیمت', 'سرویسِ منبع', 'رزرو واقعی'], $rows);
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user