/** * Quick create: a port of razorbooking-admin * resources/js/features/services/components/CreateServiceDrawer.tsx, with the * same fields and payload (Razorbooking_Service_Input_Dto). "Advanced" * opens the full editor in RazorBooking. * * Difference: the price may be left empty for a free service. The category is * optional, as in RazorBooking. */ import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { Button, Drawer, DurationField, MultiSelect, ResourceSelector, Select, TextField, type ResourceNeed, type ResourcePoolOption, } from '../../ds'; import { SquarePen } from '../../design-system/icons'; import { api } from '../../lib/api'; import { errorOf } from '../../lib/errorOf'; import { openHandoff } from '../../lib/handoff'; import { CategoryFormDrawer } from '../categories/CategoryFormDrawer'; import type { ResourceRow } from '../resources/types'; export interface CreateServiceDrawerProps { open: boolean; onClose: () => void; onCreated: () => void; categories: { id: number | string; name: string }[]; resources: ResourceRow[]; googleMeet: { activated: boolean; tenant_integration_id: number | null }; currencySymbol: string; /** May create a category in place ("New category"). */ canCreateCategory: boolean; /** Edits are paused while RazorBooking is unreachable. */ writable: boolean; } interface Draft { name: string; duration: string; price: string; categories: string[]; mode: 'in_store' | 'video_conference'; resources: { resource_id: number; quantity_needed: number }[]; } /** The label of the `video_conference` mode, as in the admin's ServiceModeField.tsx. */ const GOOGLE_MEET_LABEL = 'Google Meet'; export function CreateServiceDrawer( { open, onClose, onCreated, categories, resources, googleMeet, currencySymbol, canCreateCategory, writable, }: CreateServiceDrawerProps ) { // Categories made in place during this drawer's life, before the table reloads. const [ extra, setExtra ] = useState< { id: number | string; name: string }[] >( [] ); const allCategories = useMemo( // The list's ids are numbers and a new category's a string: compare as text. () => [ ...categories, ...extra.filter( ( c ) => ! categories.some( ( k ) => String( k.id ) === String( c.id ) ) ) ], [ categories, extra ] ); const blank = (): Draft => ( { name: '', duration: '30', price: '', categories: [], mode: 'in_store', resources: [], } ); const [ draft, setDraft ] = useState< Draft >( blank ); const [ busy, setBusy ] = useState( false ); const [ error, setError ] = useState< ReactNode >( null ); const [ creatingCategory, setCreatingCategory ] = useState( false ); useEffect( () => { if ( open ) { setDraft( blank() ); setError( null ); setExtra( [] ); setCreatingCategory( false ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ open ] ); const meetReady = googleMeet.activated && googleMeet.tenant_integration_id != null; const noCategories = allCategories.length === 0; const ready = writable && draft.name.trim() !== '' && ! busy; // Paused resources are listed but cannot be picked. const pools: ResourcePoolOption[] = useMemo( () => resources.map( ( r ) => ( { value: String( r.id ), label: r.name, quantity: r.quantity, paused: ! r.is_active, } ) ), [ resources ] ); const needs: ResourceNeed[] = draft.resources.map( ( r ) => ( { resource: String( r.resource_id ), units: r.quantity_needed } ) ); const setNeeds = ( next: ResourceNeed[] ) => setDraft( ( d ) => ( { ...d, resources: next .filter( ( n ) => n.resource ) .map( ( n ) => ( { resource_id: Number( n.resource ), quantity_needed: Math.max( 1, Number( n.units ) || 1 ) } ) ), } ) ); const submit = async () => { if ( ! ready ) return; setBusy( true ); setError( null ); try { await api.post( 'services', { name: draft.name.trim(), duration: Number( draft.duration ) || 30, price: draft.price, categories: draft.categories.map( Number ), service_type: draft.mode, resources: draft.resources, } ); onCreated(); onClose(); } catch ( caught ) { setError( errorOf( caught, 'Couldn’t create the service. Try again.' ) ); } finally { setBusy( false ); } }; return (
} >
setDraft( { ...draft, name: e.target.value } ) } />
( { value: String( c.id ), label: c.name } ) ) } value={ draft.categories } onChange={ ( next ) => setDraft( { ...draft, categories: next } ) } /> { canCreateCategory ? ( ) : null }
setDraft( { ...draft, duration: minutes } ) } /> setDraft( { ...draft, price: e.target.value.replace( /[^0-9.]/g, '' ) } ) } />
{ /* Google Meet is listed but disabled until it is turned on in RazorBooking. */ }