chave-mfe-supplier 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +1 -0
- package/.github/workflows/ci.yml +38 -0
- package/Dockerfile +13 -0
- package/README.md +94 -0
- package/index.html +12 -0
- package/jest.config.cjs +34 -0
- package/package.json +37 -0
- package/src/__tests__/SupplierDetailPage.test.tsx +75 -0
- package/src/__tests__/SupplierFormPage.test.tsx +53 -0
- package/src/__tests__/SupplierListPage.test.tsx +48 -0
- package/src/__tests__/client.test.tsx +22 -0
- package/src/__tests__/dialogs.test.tsx +42 -0
- package/src/__tests__/setup.ts +5 -0
- package/src/api/client.ts +63 -0
- package/src/api/createApi.ts +7 -0
- package/src/api/types.ts +94 -0
- package/src/components/LinkProductDialog.tsx +55 -0
- package/src/components/ReplenishmentDialog.tsx +88 -0
- package/src/main.tsx +9 -0
- package/src/pages/SupplierApp.tsx +50 -0
- package/src/pages/SupplierDetailPage.tsx +210 -0
- package/src/pages/SupplierFormPage.tsx +214 -0
- package/src/pages/SupplierListPage.tsx +162 -0
- package/src/theme.ts +14 -0
- package/src/vite-env.d.ts +9 -0
- package/tsconfig.json +27 -0
- package/tsconfig.node.json +10 -0
- package/vite.config.ts +26 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { FC, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField,
|
|
4
|
+
} from "@mui/material";
|
|
5
|
+
import { LinkProductPayload } from "../api/types";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
open: boolean;
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
onSubmit: (payload: LinkProductPayload) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const num = (v: string): number | null => (v.trim() === "" ? null : Number(v));
|
|
14
|
+
|
|
15
|
+
const LinkProductDialog: FC<Props> = ({ open, onClose, onSubmit }) => {
|
|
16
|
+
const [productId, setProductId] = useState("");
|
|
17
|
+
const [supplyPrice, setSupplyPrice] = useState("");
|
|
18
|
+
const [leadTimeDays, setLeadTimeDays] = useState("");
|
|
19
|
+
const [supplierSku, setSupplierSku] = useState("");
|
|
20
|
+
|
|
21
|
+
const handleSubmit = () => {
|
|
22
|
+
if (!productId.trim()) return;
|
|
23
|
+
onSubmit({
|
|
24
|
+
productId: productId.trim(),
|
|
25
|
+
supplyPrice: num(supplyPrice),
|
|
26
|
+
leadTimeDays: num(leadTimeDays),
|
|
27
|
+
supplierSku: supplierSku.trim() || null,
|
|
28
|
+
});
|
|
29
|
+
setProductId(""); setSupplyPrice(""); setLeadTimeDays(""); setSupplierSku("");
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
|
|
34
|
+
<DialogTitle>Vincular produto</DialogTitle>
|
|
35
|
+
<DialogContent>
|
|
36
|
+
<Stack spacing={2} sx={{ mt: 1 }}>
|
|
37
|
+
<TextField label="ID do produto" value={productId} required
|
|
38
|
+
onChange={(e) => setProductId(e.target.value)} />
|
|
39
|
+
<TextField label="Preço de fornecimento" type="number" value={supplyPrice}
|
|
40
|
+
onChange={(e) => setSupplyPrice(e.target.value)} />
|
|
41
|
+
<TextField label="Lead time (dias)" type="number" value={leadTimeDays}
|
|
42
|
+
onChange={(e) => setLeadTimeDays(e.target.value)} />
|
|
43
|
+
<TextField label="SKU do fornecedor" value={supplierSku}
|
|
44
|
+
onChange={(e) => setSupplierSku(e.target.value)} />
|
|
45
|
+
</Stack>
|
|
46
|
+
</DialogContent>
|
|
47
|
+
<DialogActions>
|
|
48
|
+
<Button variant="text" onClick={onClose}>Cancelar</Button>
|
|
49
|
+
<Button onClick={handleSubmit} disabled={!productId.trim()}>Vincular</Button>
|
|
50
|
+
</DialogActions>
|
|
51
|
+
</Dialog>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export default LinkProductDialog;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { FC, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, MenuItem,
|
|
4
|
+
Stack, TextField, Typography,
|
|
5
|
+
} from "@mui/material";
|
|
6
|
+
import DeleteIcon from "@mui/icons-material/Delete";
|
|
7
|
+
import { CreateReplenishmentPayload, ReplenishmentStatus } from "../api/types";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
open: boolean;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
onSubmit: (payload: CreateReplenishmentPayload) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ItemRow {
|
|
16
|
+
productId: string;
|
|
17
|
+
quantity: string;
|
|
18
|
+
unitCost: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const STATUSES: ReplenishmentStatus[] = ["requested", "sent", "received", "cancelled"];
|
|
22
|
+
const STATUS_LABEL: Record<ReplenishmentStatus, string> = {
|
|
23
|
+
requested: "Solicitado", sent: "Enviado", received: "Recebido", cancelled: "Cancelado",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const emptyRow: ItemRow = { productId: "", quantity: "1", unitCost: "" };
|
|
27
|
+
|
|
28
|
+
const ReplenishmentDialog: FC<Props> = ({ open, onClose, onSubmit }) => {
|
|
29
|
+
const [status, setStatus] = useState<ReplenishmentStatus>("requested");
|
|
30
|
+
const [items, setItems] = useState<ItemRow[]>([{ ...emptyRow }]);
|
|
31
|
+
|
|
32
|
+
const setItem = (idx: number, field: keyof ItemRow, value: string) =>
|
|
33
|
+
setItems((rows) => rows.map((r, i) => (i === idx ? { ...r, [field]: value } : r)));
|
|
34
|
+
const addRow = () => setItems((rows) => [...rows, { ...emptyRow }]);
|
|
35
|
+
const removeRow = (idx: number) => setItems((rows) => rows.filter((_, i) => i !== idx));
|
|
36
|
+
|
|
37
|
+
const valid = items.length > 0 && items.every((r) => r.productId.trim() && Number(r.quantity) > 0);
|
|
38
|
+
|
|
39
|
+
const handleSubmit = () => {
|
|
40
|
+
if (!valid) return;
|
|
41
|
+
onSubmit({
|
|
42
|
+
status,
|
|
43
|
+
items: items.map((r) => ({
|
|
44
|
+
productId: r.productId.trim(),
|
|
45
|
+
quantity: Number(r.quantity),
|
|
46
|
+
unitCost: r.unitCost.trim() === "" ? null : Number(r.unitCost),
|
|
47
|
+
})),
|
|
48
|
+
});
|
|
49
|
+
setStatus("requested");
|
|
50
|
+
setItems([{ ...emptyRow }]);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
|
55
|
+
<DialogTitle>Registrar reposição</DialogTitle>
|
|
56
|
+
<DialogContent>
|
|
57
|
+
<Stack spacing={2} sx={{ mt: 1 }}>
|
|
58
|
+
<TextField select label="Status" value={status} sx={{ maxWidth: 220 }}
|
|
59
|
+
onChange={(e) => setStatus(e.target.value as ReplenishmentStatus)}>
|
|
60
|
+
{STATUSES.map((s) => <MenuItem key={s} value={s}>{STATUS_LABEL[s]}</MenuItem>)}
|
|
61
|
+
</TextField>
|
|
62
|
+
<Typography variant="subtitle2">Itens</Typography>
|
|
63
|
+
{items.map((row, idx) => (
|
|
64
|
+
<Stack key={idx} direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
|
65
|
+
<TextField label="ID do produto" value={row.productId} sx={{ flex: 1 }}
|
|
66
|
+
onChange={(e) => setItem(idx, "productId", e.target.value)} />
|
|
67
|
+
<TextField label="Qtd" type="number" value={row.quantity} sx={{ width: 100 }}
|
|
68
|
+
onChange={(e) => setItem(idx, "quantity", e.target.value)} />
|
|
69
|
+
<TextField label="Custo unit." type="number" value={row.unitCost} sx={{ width: 140 }}
|
|
70
|
+
onChange={(e) => setItem(idx, "unitCost", e.target.value)} />
|
|
71
|
+
<IconButton aria-label="Remover item" disabled={items.length === 1}
|
|
72
|
+
onClick={() => removeRow(idx)}><DeleteIcon /></IconButton>
|
|
73
|
+
</Stack>
|
|
74
|
+
))}
|
|
75
|
+
<Button variant="text" onClick={addRow} sx={{ alignSelf: "flex-start" }}>
|
|
76
|
+
Adicionar item
|
|
77
|
+
</Button>
|
|
78
|
+
</Stack>
|
|
79
|
+
</DialogContent>
|
|
80
|
+
<DialogActions>
|
|
81
|
+
<Button variant="text" onClick={onClose}>Cancelar</Button>
|
|
82
|
+
<Button onClick={handleSubmit} disabled={!valid}>Registrar</Button>
|
|
83
|
+
</DialogActions>
|
|
84
|
+
</Dialog>
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export default ReplenishmentDialog;
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { FC, useMemo, useState } from "react";
|
|
2
|
+
import { ThemeProvider, CssBaseline } from "@mui/material";
|
|
3
|
+
import { theme } from "../theme";
|
|
4
|
+
import { createApi } from "../api/createApi";
|
|
5
|
+
import SupplierListPage from "./SupplierListPage";
|
|
6
|
+
import SupplierFormPage from "./SupplierFormPage";
|
|
7
|
+
import SupplierDetailPage from "./SupplierDetailPage";
|
|
8
|
+
|
|
9
|
+
type View =
|
|
10
|
+
| { name: "list" }
|
|
11
|
+
| { name: "create" }
|
|
12
|
+
| { name: "edit"; id: string }
|
|
13
|
+
| { name: "detail"; id: string };
|
|
14
|
+
|
|
15
|
+
const SupplierApp: FC = () => {
|
|
16
|
+
const api = useMemo(() => createApi(), []);
|
|
17
|
+
const [view, setView] = useState<View>({ name: "list" });
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<ThemeProvider theme={theme}>
|
|
21
|
+
<CssBaseline />
|
|
22
|
+
{view.name === "list" && (
|
|
23
|
+
<SupplierListPage
|
|
24
|
+
api={api}
|
|
25
|
+
onCreate={() => setView({ name: "create" })}
|
|
26
|
+
onOpen={(id) => setView({ name: "detail", id })}
|
|
27
|
+
onEdit={(id) => setView({ name: "edit", id })}
|
|
28
|
+
/>
|
|
29
|
+
)}
|
|
30
|
+
{(view.name === "create" || view.name === "edit") && (
|
|
31
|
+
<SupplierFormPage
|
|
32
|
+
api={api}
|
|
33
|
+
supplierId={view.name === "edit" ? view.id : undefined}
|
|
34
|
+
onDone={() => setView({ name: "list" })}
|
|
35
|
+
onCancel={() => setView({ name: "list" })}
|
|
36
|
+
/>
|
|
37
|
+
)}
|
|
38
|
+
{view.name === "detail" && (
|
|
39
|
+
<SupplierDetailPage
|
|
40
|
+
api={api}
|
|
41
|
+
supplierId={view.id}
|
|
42
|
+
onBack={() => setView({ name: "list" })}
|
|
43
|
+
onEdit={(id) => setView({ name: "edit", id })}
|
|
44
|
+
/>
|
|
45
|
+
)}
|
|
46
|
+
</ThemeProvider>
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export default SupplierApp;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { FC, useCallback, useEffect, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Stack, Tab, Table, TableBody,
|
|
4
|
+
TableCell, TableHead, TableRow, Tabs, Typography,
|
|
5
|
+
} from "@mui/material";
|
|
6
|
+
import { SupplierApi } from "../api/client";
|
|
7
|
+
import {
|
|
8
|
+
LinkProductPayload, CreateReplenishmentPayload, ReplenishmentOrder, Supplier, SupplierProduct,
|
|
9
|
+
} from "../api/types";
|
|
10
|
+
import LinkProductDialog from "../components/LinkProductDialog";
|
|
11
|
+
import ReplenishmentDialog from "../components/ReplenishmentDialog";
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
api: SupplierApi;
|
|
15
|
+
supplierId: string;
|
|
16
|
+
onBack: () => void;
|
|
17
|
+
onEdit: (id: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const STATUS_LABEL: Record<string, string> = {
|
|
21
|
+
requested: "Solicitado", sent: "Enviado", received: "Recebido", cancelled: "Cancelado",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const SupplierDetailPage: FC<Props> = ({ api, supplierId, onBack, onEdit }) => {
|
|
25
|
+
const [supplier, setSupplier] = useState<Supplier | null>(null);
|
|
26
|
+
const [products, setProducts] = useState<SupplierProduct[]>([]);
|
|
27
|
+
const [orders, setOrders] = useState<ReplenishmentOrder[]>([]);
|
|
28
|
+
const [tab, setTab] = useState(0);
|
|
29
|
+
const [loading, setLoading] = useState(true);
|
|
30
|
+
const [error, setError] = useState<string | null>(null);
|
|
31
|
+
const [linkOpen, setLinkOpen] = useState(false);
|
|
32
|
+
const [replOpen, setReplOpen] = useState(false);
|
|
33
|
+
|
|
34
|
+
const load = useCallback(async () => {
|
|
35
|
+
setLoading(true);
|
|
36
|
+
setError(null);
|
|
37
|
+
try {
|
|
38
|
+
const [s, p, o] = await Promise.all([
|
|
39
|
+
api.getSupplier(supplierId),
|
|
40
|
+
api.listProducts(supplierId),
|
|
41
|
+
api.listReplenishments(supplierId),
|
|
42
|
+
]);
|
|
43
|
+
setSupplier(s);
|
|
44
|
+
setProducts(p);
|
|
45
|
+
setOrders(o);
|
|
46
|
+
} catch (e) {
|
|
47
|
+
setError(e instanceof Error ? e.message : "Erro ao carregar fornecedor");
|
|
48
|
+
} finally {
|
|
49
|
+
setLoading(false);
|
|
50
|
+
}
|
|
51
|
+
}, [api, supplierId]);
|
|
52
|
+
|
|
53
|
+
useEffect(() => { void load(); }, [load]);
|
|
54
|
+
|
|
55
|
+
const handleLink = async (payload: LinkProductPayload) => {
|
|
56
|
+
setLinkOpen(false);
|
|
57
|
+
try {
|
|
58
|
+
await api.linkProduct(supplierId, payload);
|
|
59
|
+
await load();
|
|
60
|
+
} catch (e) {
|
|
61
|
+
setError(e instanceof Error ? e.message : "Erro ao vincular produto");
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const handleUnlink = async (productId: string) => {
|
|
66
|
+
try {
|
|
67
|
+
await api.unlinkProduct(supplierId, productId);
|
|
68
|
+
await load();
|
|
69
|
+
} catch (e) {
|
|
70
|
+
setError(e instanceof Error ? e.message : "Erro ao desvincular produto");
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const handleReplenishment = async (payload: CreateReplenishmentPayload) => {
|
|
75
|
+
setReplOpen(false);
|
|
76
|
+
try {
|
|
77
|
+
await api.createReplenishment(supplierId, payload);
|
|
78
|
+
await load();
|
|
79
|
+
} catch (e) {
|
|
80
|
+
setError(e instanceof Error ? e.message : "Erro ao registrar reposição");
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (loading) {
|
|
85
|
+
return <Box sx={{ p: 4, textAlign: "center" }}><CircularProgress /></Box>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!supplier) {
|
|
89
|
+
return (
|
|
90
|
+
<Box sx={{ p: 3 }}>
|
|
91
|
+
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
|
92
|
+
<Button variant="text" onClick={onBack}>Voltar</Button>
|
|
93
|
+
</Box>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<Box sx={{ p: 3, maxWidth: 960, mx: "auto" }}>
|
|
99
|
+
<Stack direction="row" sx={{ mb: 2, justifyContent: "space-between", alignItems: "center" }}>
|
|
100
|
+
<Stack direction="row" spacing={2} sx={{ alignItems: "center" }}>
|
|
101
|
+
<Typography variant="h5">{supplier.legalName}</Typography>
|
|
102
|
+
<Chip size="small" label={supplier.status === "active" ? "Ativo" : "Inativo"}
|
|
103
|
+
color={supplier.status === "active" ? "success" : "default"} />
|
|
104
|
+
</Stack>
|
|
105
|
+
<Stack direction="row" spacing={1}>
|
|
106
|
+
<Button variant="text" onClick={onBack}>Voltar</Button>
|
|
107
|
+
<Button onClick={() => onEdit(supplier.id)}>Editar</Button>
|
|
108
|
+
</Stack>
|
|
109
|
+
</Stack>
|
|
110
|
+
|
|
111
|
+
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
|
112
|
+
|
|
113
|
+
<Paper>
|
|
114
|
+
<Tabs value={tab} onChange={(_e, v) => setTab(v)}>
|
|
115
|
+
<Tab label="Dados" />
|
|
116
|
+
<Tab label="Produtos vinculados" />
|
|
117
|
+
<Tab label="Reposições" />
|
|
118
|
+
</Tabs>
|
|
119
|
+
<Divider />
|
|
120
|
+
|
|
121
|
+
{tab === 0 && (
|
|
122
|
+
<Box sx={{ p: 3 }}>
|
|
123
|
+
<Stack spacing={1}>
|
|
124
|
+
<Typography><b>Nome fantasia:</b> {supplier.tradeName ?? "—"}</Typography>
|
|
125
|
+
<Typography><b>Documento:</b> {supplier.document} ({supplier.documentType.toUpperCase()})</Typography>
|
|
126
|
+
<Typography><b>E-mail:</b> {supplier.email}</Typography>
|
|
127
|
+
<Typography><b>Telefone:</b> {supplier.phone ?? "—"}</Typography>
|
|
128
|
+
<Typography><b>Contato:</b> {supplier.contactPerson ?? "—"}</Typography>
|
|
129
|
+
<Typography><b>Cidade/UF:</b> {supplier.address?.city ?? "—"} / {supplier.address?.state ?? "—"}</Typography>
|
|
130
|
+
</Stack>
|
|
131
|
+
</Box>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{tab === 1 && (
|
|
135
|
+
<Box sx={{ p: 3 }}>
|
|
136
|
+
<Stack direction="row" sx={{ mb: 2, justifyContent: "flex-end" }}>
|
|
137
|
+
<Button onClick={() => setLinkOpen(true)}>Vincular produto</Button>
|
|
138
|
+
</Stack>
|
|
139
|
+
<Table size="small">
|
|
140
|
+
<TableHead>
|
|
141
|
+
<TableRow>
|
|
142
|
+
<TableCell>Produto</TableCell>
|
|
143
|
+
<TableCell>Preço</TableCell>
|
|
144
|
+
<TableCell>Lead time</TableCell>
|
|
145
|
+
<TableCell>SKU</TableCell>
|
|
146
|
+
<TableCell align="right">Ações</TableCell>
|
|
147
|
+
</TableRow>
|
|
148
|
+
</TableHead>
|
|
149
|
+
<TableBody>
|
|
150
|
+
{products.length === 0 && (
|
|
151
|
+
<TableRow><TableCell colSpan={5}>Nenhum produto vinculado.</TableCell></TableRow>
|
|
152
|
+
)}
|
|
153
|
+
{products.map((p) => (
|
|
154
|
+
<TableRow key={p.id}>
|
|
155
|
+
<TableCell>{p.productId}</TableCell>
|
|
156
|
+
<TableCell>{p.supplyPrice ?? "—"}</TableCell>
|
|
157
|
+
<TableCell>{p.leadTimeDays ?? "—"}</TableCell>
|
|
158
|
+
<TableCell>{p.supplierSku ?? "—"}</TableCell>
|
|
159
|
+
<TableCell align="right">
|
|
160
|
+
<Button size="small" variant="text" color="error"
|
|
161
|
+
onClick={() => handleUnlink(p.productId)}>Desvincular</Button>
|
|
162
|
+
</TableCell>
|
|
163
|
+
</TableRow>
|
|
164
|
+
))}
|
|
165
|
+
</TableBody>
|
|
166
|
+
</Table>
|
|
167
|
+
</Box>
|
|
168
|
+
)}
|
|
169
|
+
|
|
170
|
+
{tab === 2 && (
|
|
171
|
+
<Box sx={{ p: 3 }}>
|
|
172
|
+
<Stack direction="row" sx={{ mb: 2, justifyContent: "flex-end" }}>
|
|
173
|
+
<Button onClick={() => setReplOpen(true)}>Registrar reposição</Button>
|
|
174
|
+
</Stack>
|
|
175
|
+
<Table size="small">
|
|
176
|
+
<TableHead>
|
|
177
|
+
<TableRow>
|
|
178
|
+
<TableCell>Pedido</TableCell>
|
|
179
|
+
<TableCell>Status</TableCell>
|
|
180
|
+
<TableCell>Total</TableCell>
|
|
181
|
+
<TableCell>Itens</TableCell>
|
|
182
|
+
<TableCell>Data</TableCell>
|
|
183
|
+
</TableRow>
|
|
184
|
+
</TableHead>
|
|
185
|
+
<TableBody>
|
|
186
|
+
{orders.length === 0 && (
|
|
187
|
+
<TableRow><TableCell colSpan={5}>Nenhuma reposição registrada.</TableCell></TableRow>
|
|
188
|
+
)}
|
|
189
|
+
{orders.map((o) => (
|
|
190
|
+
<TableRow key={o.id}>
|
|
191
|
+
<TableCell>{o.id}</TableCell>
|
|
192
|
+
<TableCell>{STATUS_LABEL[o.status] ?? o.status}</TableCell>
|
|
193
|
+
<TableCell>{o.totalCost ?? "—"}</TableCell>
|
|
194
|
+
<TableCell>{o.items.length}</TableCell>
|
|
195
|
+
<TableCell>{o.orderedAt}</TableCell>
|
|
196
|
+
</TableRow>
|
|
197
|
+
))}
|
|
198
|
+
</TableBody>
|
|
199
|
+
</Table>
|
|
200
|
+
</Box>
|
|
201
|
+
)}
|
|
202
|
+
</Paper>
|
|
203
|
+
|
|
204
|
+
<LinkProductDialog open={linkOpen} onClose={() => setLinkOpen(false)} onSubmit={handleLink} />
|
|
205
|
+
<ReplenishmentDialog open={replOpen} onClose={() => setReplOpen(false)} onSubmit={handleReplenishment} />
|
|
206
|
+
</Box>
|
|
207
|
+
);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export default SupplierDetailPage;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { FC, useEffect, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Alert, Box, Button, CircularProgress, Divider, Grid, Paper, Stack, TextField, Typography,
|
|
4
|
+
} from "@mui/material";
|
|
5
|
+
import { SupplierApi } from "../api/client";
|
|
6
|
+
import { Address, CreateSupplierPayload } from "../api/types";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
api: SupplierApi;
|
|
10
|
+
supplierId?: string;
|
|
11
|
+
onDone: () => void;
|
|
12
|
+
onCancel: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface FormState {
|
|
16
|
+
legalName: string;
|
|
17
|
+
tradeName: string;
|
|
18
|
+
document: string;
|
|
19
|
+
email: string;
|
|
20
|
+
phone: string;
|
|
21
|
+
contactPerson: string;
|
|
22
|
+
address: Required<{ [K in keyof Address]: string }>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const emptyAddress = {
|
|
26
|
+
street: "", number: "", complement: "", district: "",
|
|
27
|
+
city: "", state: "", zipCode: "", country: "",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const emptyForm: FormState = {
|
|
31
|
+
legalName: "", tradeName: "", document: "", email: "", phone: "", contactPerson: "",
|
|
32
|
+
address: { ...emptyAddress },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type Errors = Partial<Record<"legalName" | "document" | "email", string>>;
|
|
36
|
+
|
|
37
|
+
const isEmail = (v: string): boolean => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
|
|
38
|
+
|
|
39
|
+
const SupplierFormPage: FC<Props> = ({ api, supplierId, onDone, onCancel }) => {
|
|
40
|
+
const isEdit = Boolean(supplierId);
|
|
41
|
+
const [form, setForm] = useState<FormState>(emptyForm);
|
|
42
|
+
const [errors, setErrors] = useState<Errors>({});
|
|
43
|
+
const [loading, setLoading] = useState(false);
|
|
44
|
+
const [saving, setSaving] = useState(false);
|
|
45
|
+
const [error, setError] = useState<string | null>(null);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!supplierId) return;
|
|
49
|
+
setLoading(true);
|
|
50
|
+
api.getSupplier(supplierId)
|
|
51
|
+
.then((s) => setForm({
|
|
52
|
+
legalName: s.legalName ?? "",
|
|
53
|
+
tradeName: s.tradeName ?? "",
|
|
54
|
+
document: s.document ?? "",
|
|
55
|
+
email: s.email ?? "",
|
|
56
|
+
phone: s.phone ?? "",
|
|
57
|
+
contactPerson: s.contactPerson ?? "",
|
|
58
|
+
address: {
|
|
59
|
+
street: s.address?.street ?? "", number: s.address?.number ?? "",
|
|
60
|
+
complement: s.address?.complement ?? "", district: s.address?.district ?? "",
|
|
61
|
+
city: s.address?.city ?? "", state: s.address?.state ?? "",
|
|
62
|
+
zipCode: s.address?.zipCode ?? "", country: s.address?.country ?? "",
|
|
63
|
+
},
|
|
64
|
+
}))
|
|
65
|
+
.catch((e) => setError(e instanceof Error ? e.message : "Erro ao carregar fornecedor"))
|
|
66
|
+
.finally(() => setLoading(false));
|
|
67
|
+
}, [api, supplierId]);
|
|
68
|
+
|
|
69
|
+
const setField = (field: keyof FormState, value: string) =>
|
|
70
|
+
setForm((f) => ({ ...f, [field]: value }));
|
|
71
|
+
const setAddr = (field: keyof Address, value: string) =>
|
|
72
|
+
setForm((f) => ({ ...f, address: { ...f.address, [field]: value } }));
|
|
73
|
+
|
|
74
|
+
const validate = (): boolean => {
|
|
75
|
+
const next: Errors = {};
|
|
76
|
+
if (!form.legalName.trim()) next.legalName = "Razão Social é obrigatória";
|
|
77
|
+
if (!form.document.trim()) next.document = "Documento é obrigatório";
|
|
78
|
+
if (!isEmail(form.email)) next.email = "E-mail inválido";
|
|
79
|
+
setErrors(next);
|
|
80
|
+
return Object.keys(next).length === 0;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handleSubmit = async () => {
|
|
84
|
+
if (!validate()) return;
|
|
85
|
+
setSaving(true);
|
|
86
|
+
setError(null);
|
|
87
|
+
const nz = (v: string): string | null => (v.trim() ? v.trim() : null);
|
|
88
|
+
const address: Address = {
|
|
89
|
+
street: nz(form.address.street), number: nz(form.address.number),
|
|
90
|
+
complement: nz(form.address.complement), district: nz(form.address.district),
|
|
91
|
+
city: nz(form.address.city), state: nz(form.address.state),
|
|
92
|
+
zipCode: nz(form.address.zipCode), country: nz(form.address.country),
|
|
93
|
+
};
|
|
94
|
+
try {
|
|
95
|
+
if (isEdit && supplierId) {
|
|
96
|
+
await api.updateSupplier(supplierId, {
|
|
97
|
+
legalName: form.legalName, tradeName: nz(form.tradeName), email: form.email,
|
|
98
|
+
phone: nz(form.phone), contactPerson: nz(form.contactPerson), address,
|
|
99
|
+
});
|
|
100
|
+
} else {
|
|
101
|
+
const payload: CreateSupplierPayload = {
|
|
102
|
+
legalName: form.legalName, tradeName: nz(form.tradeName), document: form.document,
|
|
103
|
+
email: form.email, phone: nz(form.phone), contactPerson: nz(form.contactPerson), address,
|
|
104
|
+
};
|
|
105
|
+
await api.createSupplier(payload);
|
|
106
|
+
}
|
|
107
|
+
onDone();
|
|
108
|
+
} catch (e) {
|
|
109
|
+
setError(e instanceof Error ? e.message : "Erro ao salvar fornecedor");
|
|
110
|
+
} finally {
|
|
111
|
+
setSaving(false);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
if (loading) {
|
|
116
|
+
return <Box sx={{ p: 4, textAlign: "center" }}><CircularProgress /></Box>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<Box sx={{ p: 3, maxWidth: 880, mx: "auto" }}>
|
|
121
|
+
<Typography variant="h5" sx={{ mb: 2 }}>
|
|
122
|
+
{isEdit ? "Editar fornecedor" : "Novo fornecedor"}
|
|
123
|
+
</Typography>
|
|
124
|
+
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
|
125
|
+
<Paper sx={{ p: 3 }}>
|
|
126
|
+
<Grid container spacing={2}>
|
|
127
|
+
<Grid size={{ xs: 12, sm: 6 }}>
|
|
128
|
+
<TextField
|
|
129
|
+
fullWidth label="Razão Social" value={form.legalName}
|
|
130
|
+
error={Boolean(errors.legalName)} helperText={errors.legalName}
|
|
131
|
+
onChange={(e) => setField("legalName", e.target.value)}
|
|
132
|
+
/>
|
|
133
|
+
</Grid>
|
|
134
|
+
<Grid size={{ xs: 12, sm: 6 }}>
|
|
135
|
+
<TextField
|
|
136
|
+
fullWidth label="Nome Fantasia" value={form.tradeName}
|
|
137
|
+
onChange={(e) => setField("tradeName", e.target.value)}
|
|
138
|
+
/>
|
|
139
|
+
</Grid>
|
|
140
|
+
<Grid size={{ xs: 12, sm: 6 }}>
|
|
141
|
+
<TextField
|
|
142
|
+
fullWidth label="Documento (CNPJ/CPF)" value={form.document}
|
|
143
|
+
disabled={isEdit}
|
|
144
|
+
error={Boolean(errors.document)} helperText={errors.document}
|
|
145
|
+
onChange={(e) => setField("document", e.target.value)}
|
|
146
|
+
/>
|
|
147
|
+
</Grid>
|
|
148
|
+
<Grid size={{ xs: 12, sm: 6 }}>
|
|
149
|
+
<TextField
|
|
150
|
+
fullWidth label="E-mail" value={form.email}
|
|
151
|
+
error={Boolean(errors.email)} helperText={errors.email}
|
|
152
|
+
onChange={(e) => setField("email", e.target.value)}
|
|
153
|
+
/>
|
|
154
|
+
</Grid>
|
|
155
|
+
<Grid size={{ xs: 12, sm: 6 }}>
|
|
156
|
+
<TextField
|
|
157
|
+
fullWidth label="Telefone" value={form.phone}
|
|
158
|
+
onChange={(e) => setField("phone", e.target.value)}
|
|
159
|
+
/>
|
|
160
|
+
</Grid>
|
|
161
|
+
<Grid size={{ xs: 12, sm: 6 }}>
|
|
162
|
+
<TextField
|
|
163
|
+
fullWidth label="Pessoa de contato" value={form.contactPerson}
|
|
164
|
+
onChange={(e) => setField("contactPerson", e.target.value)}
|
|
165
|
+
/>
|
|
166
|
+
</Grid>
|
|
167
|
+
|
|
168
|
+
<Grid size={12}><Divider>Endereço</Divider></Grid>
|
|
169
|
+
<Grid size={{ xs: 12, sm: 8 }}>
|
|
170
|
+
<TextField fullWidth label="Logradouro" value={form.address.street}
|
|
171
|
+
onChange={(e) => setAddr("street", e.target.value)} />
|
|
172
|
+
</Grid>
|
|
173
|
+
<Grid size={{ xs: 12, sm: 4 }}>
|
|
174
|
+
<TextField fullWidth label="Número" value={form.address.number}
|
|
175
|
+
onChange={(e) => setAddr("number", e.target.value)} />
|
|
176
|
+
</Grid>
|
|
177
|
+
<Grid size={{ xs: 12, sm: 6 }}>
|
|
178
|
+
<TextField fullWidth label="Complemento" value={form.address.complement}
|
|
179
|
+
onChange={(e) => setAddr("complement", e.target.value)} />
|
|
180
|
+
</Grid>
|
|
181
|
+
<Grid size={{ xs: 12, sm: 6 }}>
|
|
182
|
+
<TextField fullWidth label="Bairro" value={form.address.district}
|
|
183
|
+
onChange={(e) => setAddr("district", e.target.value)} />
|
|
184
|
+
</Grid>
|
|
185
|
+
<Grid size={{ xs: 12, sm: 5 }}>
|
|
186
|
+
<TextField fullWidth label="Cidade" value={form.address.city}
|
|
187
|
+
onChange={(e) => setAddr("city", e.target.value)} />
|
|
188
|
+
</Grid>
|
|
189
|
+
<Grid size={{ xs: 6, sm: 3 }}>
|
|
190
|
+
<TextField fullWidth label="Estado" value={form.address.state}
|
|
191
|
+
onChange={(e) => setAddr("state", e.target.value)} />
|
|
192
|
+
</Grid>
|
|
193
|
+
<Grid size={{ xs: 6, sm: 4 }}>
|
|
194
|
+
<TextField fullWidth label="CEP" value={form.address.zipCode}
|
|
195
|
+
onChange={(e) => setAddr("zipCode", e.target.value)} />
|
|
196
|
+
</Grid>
|
|
197
|
+
<Grid size={{ xs: 12, sm: 6 }}>
|
|
198
|
+
<TextField fullWidth label="País" value={form.address.country}
|
|
199
|
+
onChange={(e) => setAddr("country", e.target.value)} />
|
|
200
|
+
</Grid>
|
|
201
|
+
</Grid>
|
|
202
|
+
|
|
203
|
+
<Stack direction="row" spacing={2} sx={{ mt: 3, justifyContent: "flex-end" }}>
|
|
204
|
+
<Button variant="text" onClick={onCancel} disabled={saving}>Cancelar</Button>
|
|
205
|
+
<Button onClick={handleSubmit} disabled={saving}>
|
|
206
|
+
{saving ? "Salvando..." : "Salvar"}
|
|
207
|
+
</Button>
|
|
208
|
+
</Stack>
|
|
209
|
+
</Paper>
|
|
210
|
+
</Box>
|
|
211
|
+
);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export default SupplierFormPage;
|