33800292aa
- 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>
264 lines
9.7 KiB
TypeScript
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 că vrei să ș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>
|
|
);
|
|
}
|