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>
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user