Files
hrm-medpark/apps/web/src/pages/inventory/InventoryDrawer.tsx
T
Danil Suhomlinov 33800292aa chore: add Coolify deployment scaffolding (Dockerfiles, prod compose, git hygiene)
- apps/api/Dockerfile: build NestJS, run prisma migrate deploy on start
- apps/web/Dockerfile + nginx.conf: build Vite, serve static, proxy /api -> api
- docker-compose.coolify.yml: full prod stack (postgres, redis, minio, keycloak, api, web)
- .dockerignore / .gitignore / .gitattributes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:42:45 +03:00

264 lines
9.7 KiB
TypeScript

import { useEffect } from 'react';
import {
Drawer, Button, Group, Stack, Text, LoadingOverlay, Box,
Select, TextInput, NumberInput, Switch, Divider,
} from '@mantine/core';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import { modals } from '@mantine/modals';
import { apiClient } from '../../api/client';
import type { InventoryItem, InventoryItemType } from '../../api/types';
const font = "'Montserrat', Arial, sans-serif";
const teal = '#008286';
const charcoal = '#58595b';
const TYPE_OPTIONS: { value: InventoryItemType; label: string }[] = [
{ value: 'uniforma', label: 'Uniformă' },
{ value: 'halat', label: 'Halat' },
{ value: 'ciupici', label: 'Ciupici' },
{ value: 'vesta', label: 'Vestă' },
{ value: 'aparat_telefon', label: 'Aparat telefon' },
{ value: 'alte', label: 'Altele' },
];
const schema = z.object({
sku: z.string().min(1, 'Câmp obligatoriu'),
name: z.string().min(1, 'Câmp obligatoriu'),
type: z.enum(['uniforma', 'halat', 'ciupici', 'vesta', 'aparat_telefon', 'alte']),
size: z.string().optional(),
color: z.string().optional(),
pricePerUnit: z.string().optional(),
stockQty: z.number().int().min(0, 'Stocul nu poate fi negativ'),
active: z.boolean(),
});
type FormValues = z.infer<typeof schema>;
interface Props {
inventoryItem: InventoryItem | null;
opened: boolean;
onClose: () => void;
}
export function InventoryDrawer({ inventoryItem, opened, onClose }: Props) {
const isEdit = !!inventoryItem;
const qc = useQueryClient();
const { handleSubmit, control, reset, formState: { errors, isSubmitting } } =
useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
sku: '',
name: '',
type: 'uniforma',
size: '',
color: '',
pricePerUnit: '',
stockQty: 0,
active: true,
},
});
useEffect(() => {
if (inventoryItem) {
reset({
sku: inventoryItem.sku,
name: inventoryItem.name,
type: inventoryItem.type,
size: inventoryItem.size ?? '',
color: inventoryItem.color ?? '',
pricePerUnit: inventoryItem.pricePerUnit ?? '',
stockQty: inventoryItem.stockQty,
active: inventoryItem.active,
});
} else {
reset({
sku: '',
name: '',
type: 'uniforma',
size: '',
color: '',
pricePerUnit: '',
stockQty: 0,
active: true,
});
}
}, [inventoryItem, opened, reset]);
const mutation = useMutation({
mutationFn: (data: FormValues) => {
const price = data.pricePerUnit ? Number(data.pricePerUnit) : undefined;
const payload = {
sku: data.sku,
name: data.name,
type: data.type,
size: data.size || undefined,
color: data.color || undefined,
pricePerUnit: Number.isFinite(price) ? price : undefined,
stockQty: data.stockQty,
active: data.active,
};
return isEdit
? apiClient.patch(`/inventory/${inventoryItem.id}`, payload)
: apiClient.post('/inventory', payload);
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['inventory'] });
notifications.show({
color: 'medpark',
title: 'Salvat',
message: isEdit ? 'Articol actualizat.' : 'Articol creat.',
});
onClose();
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
},
});
const deleteMutation = useMutation({
mutationFn: () => apiClient.delete(`/inventory/${inventoryItem!.id}`),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['inventory'] });
notifications.show({ color: 'medpark', title: 'Șters', message: 'Articol șters.' });
onClose();
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Eroare';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
},
});
function confirmDelete() {
if (!inventoryItem) return;
modals.openConfirmModal({
title: <Text fw={600} style={{ fontFamily: font }}>Șterge articol</Text>,
children: (
<Text size="sm" style={{ fontFamily: font }}>
Ești sigur vrei ștergi articolul <b>{inventoryItem.sku}</b> {inventoryItem.name}?
Această acțiune nu poate fi anulată.
</Text>
),
labels: { confirm: 'Șterge', cancel: 'Anulează' },
confirmProps: { color: 'red' },
onConfirm: () => deleteMutation.mutate(),
});
}
const section = (label: string) => (
<Divider
label={<Text size="xs" fw={700} c={teal} style={{ fontFamily: font, letterSpacing: '0.06em', textTransform: 'uppercase' }}>{label}</Text>}
labelPosition="left" my={12}
/>
);
return (
<Drawer
opened={opened}
onClose={onClose}
position="right"
size="md"
title={<Text style={{ fontFamily: font, fontWeight: 700, color: charcoal }}>{isEdit ? 'Editare articol' : 'Articol nou'}</Text>}
styles={{ header: { borderBottom: `2px solid ${teal}` } }}
>
<Box style={{ position: 'relative' }}>
<LoadingOverlay visible={isSubmitting || mutation.isPending || deleteMutation.isPending} />
<form onSubmit={handleSubmit((d) => mutation.mutate(d))}>
<Stack gap={10} pt={8}>
{section('Identificare')}
<Group grow>
<Controller name="sku" control={control} render={({ field }) => (
<TextInput {...field} label="SKU *" placeholder="UN-001"
error={errors.sku?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<Controller name="type" control={control} render={({ field }) => (
<Select label="Tip *" data={TYPE_OPTIONS}
value={field.value ?? null}
onChange={(v) => field.onChange(v ?? 'uniforma')}
error={errors.type?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
</Group>
<Controller name="name" control={control} render={({ field }) => (
<TextInput {...field} label="Denumire *" placeholder="Uniformă medicală albă"
error={errors.name?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
{section('Caracteristici')}
<Group grow>
<Controller name="size" control={control} render={({ field }) => (
<TextInput {...field} label="Mărime" placeholder="M, L, 42..."
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
<Controller name="color" control={control} render={({ field }) => (
<TextInput {...field} label="Culoare" placeholder="Alb, albastru..."
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }} />
)} />
</Group>
{section('Stoc și preț')}
<Group grow>
<Controller name="pricePerUnit" control={control} render={({ field }) => (
<NumberInput
label="Preț unitar (MDL)"
placeholder="0"
decimalScale={2}
min={0}
value={field.value ? Number(field.value) : ''}
onChange={(v) => field.onChange(v === '' || v === null ? '' : String(v))}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
)} />
<Controller name="stockQty" control={control} render={({ field }) => (
<NumberInput
label={isEdit ? 'Stoc curent *' : 'Stoc inițial *'}
min={0}
value={field.value}
onChange={(v) => field.onChange(typeof v === 'number' ? v : 0)}
error={errors.stockQty?.message}
styles={{ label: { fontFamily: font, fontWeight: 500, fontSize: '0.8rem' } }}
/>
)} />
</Group>
<Controller name="active" control={control} render={({ field }) => (
<Switch
label="Activ"
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="medpark"
styles={{ label: { fontFamily: font, fontSize: '0.875rem' } }}
mt={4}
/>
)} />
</Stack>
<Group justify="space-between" mt={20} pt={16} style={{ borderTop: '1px solid #e9ecef' }}>
{isEdit ? (
<Button color="red" variant="subtle" onClick={confirmDelete} style={{ fontFamily: font }}>
Șterge
</Button>
) : <Box />}
<Group gap={8}>
<Button variant="subtle" onClick={onClose} style={{ fontFamily: font, color: charcoal }}>
Anulează
</Button>
<Button type="submit" loading={mutation.isPending} style={{ background: teal, fontFamily: font, fontWeight: 500 }}>
{isEdit ? 'Salvează' : 'Creează'}
</Button>
</Group>
</Group>
</form>
</Box>
</Drawer>
);
}