537 lines
21 KiB
JavaScript
537 lines
21 KiB
JavaScript
import { PureComponent } from 'react';
|
|
import Alert from '@mui/material/Alert';
|
|
import Box from '@mui/material/Box';
|
|
import Button from '@mui/material/Button';
|
|
import Chip from '@mui/material/Chip';
|
|
import CircularProgress from '@mui/material/CircularProgress';
|
|
import AppDialog from './AppDialog';
|
|
import DialogContent from '@mui/material/DialogContent';
|
|
import DialogTitle from '@mui/material/DialogTitle';
|
|
import Divider from '@mui/material/Divider';
|
|
import FormControl from '@mui/material/FormControl';
|
|
import FormLabel from '@mui/material/FormLabel';
|
|
import InputAdornment from '@mui/material/InputAdornment';
|
|
import List from '@mui/material/List';
|
|
import ListItemButton from '@mui/material/ListItemButton';
|
|
import ListItemText from '@mui/material/ListItemText';
|
|
import Paper from '@mui/material/Paper';
|
|
import Stack from '@mui/material/Stack';
|
|
import Table from '@mui/material/Table';
|
|
import TableBody from '@mui/material/TableBody';
|
|
import TableCell from '@mui/material/TableCell';
|
|
import TableContainer from '@mui/material/TableContainer';
|
|
import TableHead from '@mui/material/TableHead';
|
|
import TableRow from '@mui/material/TableRow';
|
|
import TextInput from './TextInput';
|
|
import ToggleButton from '@mui/material/ToggleButton';
|
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
|
import Typography from '@mui/material/Typography';
|
|
import BusinessIcon from '@mui/icons-material/Business';
|
|
import Inventory2Icon from '@mui/icons-material/Inventory2';
|
|
import PeopleIcon from '@mui/icons-material/People';
|
|
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
|
import TuneIcon from '@mui/icons-material/Tune';
|
|
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
|
import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
|
|
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
|
import AssignmentReturnIcon from '@mui/icons-material/AssignmentReturn';
|
|
import BackendError from './BackendError';
|
|
import authAutomat from '../automats/authAutomat';
|
|
import companyAutomat from '../automats/companyAutomat';
|
|
import {
|
|
defaultIso639,
|
|
ISO639_OPTIONS,
|
|
parseTaxRate,
|
|
} from '../utils/companyForm';
|
|
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
|
|
|
|
const NUMBER_SCHEMES = [
|
|
{ type: 'sku', label: 'Artikelnummer (ArtNr)', icon: Inventory2Icon },
|
|
{ type: 'entity', label: 'Kundennummer', icon: PeopleIcon },
|
|
{ type: 'delivery', label: 'Lieferschein', icon: LocalShippingIcon },
|
|
{ type: 'adjustment', label: 'Bestandskorrektur', icon: TuneIcon },
|
|
{ type: 'quote', label: 'Angebot', icon: RequestQuoteIcon },
|
|
{ type: 'order', label: 'Bestellung', icon: ShoppingCartIcon },
|
|
{ type: 'return', label: 'Rücksendung (Lieferant)', icon: AssignmentReturnIcon },
|
|
{ type: 'retoure', label: 'Retoure (Kunde)', icon: AssignmentReturnIcon },
|
|
];
|
|
|
|
const PRESETS = [
|
|
{
|
|
id: 'standard',
|
|
label: 'Standard',
|
|
hint: 'Klassische Kürzel (ART-, KD-, LS-, KORR-, ANG-, AUF-, RTN-, RET-)',
|
|
values: {
|
|
sku: { prefix: 'ART-', padding: 6, next: 1 },
|
|
entity: { prefix: 'KD-', padding: 6, next: 1001 },
|
|
delivery: { prefix: 'LS-', padding: 6, next: 1 },
|
|
adjustment: { prefix: 'KORR-', padding: 5, next: 1 },
|
|
quote: { prefix: 'ANG-', padding: 6, next: 1 },
|
|
order: { prefix: 'AUF-', padding: 6, next: 1 },
|
|
return: { prefix: 'RTN-', padding: 6, next: 1 },
|
|
retoure: { prefix: 'RET-', padding: 6, next: 1 },
|
|
},
|
|
},
|
|
{
|
|
id: 'numeric',
|
|
label: 'Rein numerisch',
|
|
hint: 'Ohne Präfix, mit Nummernblöcken (10001, 20001…)',
|
|
values: {
|
|
sku: { prefix: '', padding: 5, next: 10001 },
|
|
entity: { prefix: '', padding: 5, next: 20001 },
|
|
delivery: { prefix: '', padding: 5, next: 30001 },
|
|
adjustment: { prefix: '', padding: 5, next: 90001 },
|
|
quote: { prefix: '', padding: 5, next: 40001 },
|
|
order: { prefix: '', padding: 5, next: 50001 },
|
|
return: { prefix: 'RTN', padding: 5, next: 1 },
|
|
retoure: { prefix: 'RET', padding: 5, next: 1 },
|
|
},
|
|
},
|
|
{
|
|
id: 'compact',
|
|
label: 'Kurzkürzel',
|
|
hint: 'Einbuchstabige Präfixe (A-, K-, L-, B-, AG-, AU-, RTN-, RET-)',
|
|
values: {
|
|
sku: { prefix: 'A-', padding: 5, next: 1 },
|
|
entity: { prefix: 'K-', padding: 5, next: 1001 },
|
|
delivery: { prefix: 'L-', padding: 5, next: 1 },
|
|
adjustment: { prefix: 'B-', padding: 5, next: 1 },
|
|
quote: { prefix: 'AG-', padding: 5, next: 1 },
|
|
order: { prefix: 'AU-', padding: 5, next: 1 },
|
|
return: { prefix: 'RTN-', padding: 5, next: 1 },
|
|
retoure: { prefix: 'RET-', padding: 5, next: 1 },
|
|
},
|
|
},
|
|
{
|
|
id: 'international',
|
|
label: 'International',
|
|
hint: 'Englische Kürzel (SKU-, C-, DEL-, ADJ-, Q-, ORD-, RTN-, RET-)',
|
|
values: {
|
|
sku: { prefix: 'SKU-', padding: 5, next: 1 },
|
|
entity: { prefix: 'C-', padding: 5, next: 1001 },
|
|
delivery: { prefix: 'DEL-', padding: 5, next: 1 },
|
|
adjustment: { prefix: 'ADJ-', padding: 4, next: 1 },
|
|
quote: { prefix: 'Q-', padding: 5, next: 1 },
|
|
order: { prefix: 'ORD-', padding: 5, next: 1 },
|
|
return: { prefix: 'RTN-', padding: 5, next: 1 },
|
|
retoure: { prefix: 'RET-', padding: 5, next: 1 },
|
|
},
|
|
},
|
|
];
|
|
|
|
function formatNumberPreview(prefix, padding, next, offset = 0) {
|
|
const pad = Math.max(0, Number(padding) || 0);
|
|
const num = Math.max(0, (Number(next) || 1) + offset);
|
|
const numStr = pad > 0 ? String(num).padStart(pad, '0') : String(num);
|
|
return `${prefix ?? ''}${numStr}`;
|
|
}
|
|
|
|
function mapCompanyState(state) {
|
|
return {
|
|
companies: Array.isArray(state?.companies) ? state.companies : [],
|
|
loading: state?.status === 'idle' || state?.status === 'loading',
|
|
};
|
|
}
|
|
|
|
class CompanySetupDialog extends PureComponent {
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = {
|
|
...mapCompanyState(companyAutomat.state),
|
|
busy: false,
|
|
error: null,
|
|
name: '',
|
|
iso639: defaultIso639(),
|
|
taxrate: '19',
|
|
reducedtaxrate: '7',
|
|
selectedPreset: 'standard',
|
|
numbers: { ...PRESETS[0].values },
|
|
};
|
|
this.handleSelect = this.handleSelect.bind(this);
|
|
this.handleCreate = this.handleCreate.bind(this);
|
|
this.handleLogout = this.handleLogout.bind(this);
|
|
this.handleClose = this.handleClose.bind(this);
|
|
this.applyPreset = this.applyPreset.bind(this);
|
|
this.handleNumberChange = this.handleNumberChange.bind(this);
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.unsubscribe = companyAutomat.subscribe(this, mapCompanyState);
|
|
this.setState(mapCompanyState(companyAutomat.state));
|
|
if (companyAutomat.state.status !== 'succeeded') {
|
|
void companyAutomat.actions.load().catch(() => {});
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this.unsubscribe?.();
|
|
}
|
|
|
|
async handleSelect(companyId) {
|
|
this.setState({ busy: true, error: null });
|
|
try {
|
|
await authAutomat.actions.selectCompany(companyId);
|
|
} catch (err) {
|
|
this.setState({ error: backendErrorFrom(err) });
|
|
} finally {
|
|
this.setState({ busy: false });
|
|
}
|
|
}
|
|
|
|
applyPreset(presetId) {
|
|
const preset = PRESETS.find((p) => p.id === presetId);
|
|
if (!preset) return;
|
|
this.setState({
|
|
selectedPreset: presetId,
|
|
numbers: { ...preset.values },
|
|
});
|
|
}
|
|
|
|
handleNumberChange(type, field, val) {
|
|
this.setState((prev) => ({
|
|
selectedPreset: 'custom',
|
|
numbers: {
|
|
...prev.numbers,
|
|
[type]: {
|
|
...prev.numbers[type],
|
|
[field]: val,
|
|
},
|
|
},
|
|
}));
|
|
}
|
|
|
|
async handleCreate(event) {
|
|
event.preventDefault();
|
|
const { name, iso639, taxrate, reducedtaxrate, numbers } = this.state;
|
|
const parsedTaxrate = parseTaxRate(taxrate);
|
|
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
|
|
const canCreate =
|
|
name.trim() !== '' &&
|
|
ISO639_OPTIONS.includes(iso639) &&
|
|
parsedTaxrate != null &&
|
|
parsedReducedtaxrate != null;
|
|
|
|
if (!canCreate) {
|
|
this.setState({ error: 'Bitte alle Pflichtfelder ausfüllen.' });
|
|
return;
|
|
}
|
|
|
|
this.setState({ busy: true, error: null });
|
|
try {
|
|
await authAutomat.actions.createCompany({
|
|
name: name.trim(),
|
|
iso639,
|
|
taxrate: parsedTaxrate,
|
|
reducedtaxrate: parsedReducedtaxrate,
|
|
numbers,
|
|
});
|
|
} catch (err) {
|
|
this.setState({ error: backendErrorFrom(err) });
|
|
} finally {
|
|
this.setState({ busy: false });
|
|
}
|
|
}
|
|
|
|
async handleLogout() {
|
|
try {
|
|
await authAutomat.actions.logout();
|
|
} catch (err) {
|
|
this.setState({ error: backendErrorFrom(err) });
|
|
}
|
|
}
|
|
|
|
handleClose(_event, reason) {
|
|
if (reason === 'backdropClick' || reason === 'escapeKeyDown') return;
|
|
}
|
|
|
|
render() {
|
|
const {
|
|
companies,
|
|
loading,
|
|
busy,
|
|
error,
|
|
name,
|
|
iso639,
|
|
taxrate,
|
|
reducedtaxrate,
|
|
selectedPreset,
|
|
numbers,
|
|
} = this.state;
|
|
const hasCompanies = companies.length > 0;
|
|
const parsedTaxrate = parseTaxRate(taxrate);
|
|
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
|
|
const canCreate =
|
|
name.trim() !== '' &&
|
|
ISO639_OPTIONS.includes(iso639) &&
|
|
parsedTaxrate != null &&
|
|
parsedReducedtaxrate != null;
|
|
|
|
return (
|
|
<AppDialog
|
|
open
|
|
onClose={this.handleClose}
|
|
maxWidth={hasCompanies ? 'sm' : 'md'}
|
|
fullWidth
|
|
>
|
|
<DialogContent>
|
|
{error ? (
|
|
isBackendFailure(error) ? (
|
|
<BackendError error={error} sx={{ mb: 2 }} />
|
|
) : (
|
|
<Alert severity="error" sx={{ mb: 2 }}>
|
|
{typeof error === 'string' ? error : error.message}
|
|
</Alert>
|
|
)
|
|
) : null}
|
|
|
|
{loading ? (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
|
<CircularProgress />
|
|
</Box>
|
|
) : (
|
|
<Stack spacing={3}>
|
|
{hasCompanies ? (
|
|
<Box>
|
|
<List disablePadding sx={{ bgcolor: 'background.paper', borderRadius: 1 }}>
|
|
{companies.map((item) => (
|
|
<ListItemButton
|
|
key={item.id}
|
|
disabled={busy}
|
|
onClick={() => this.handleSelect(item.id)}
|
|
>
|
|
<BusinessIcon sx={{ mr: 1.5, color: 'primary.main' }} />
|
|
<ListItemText primary={item.name} />
|
|
</ListItemButton>
|
|
))}
|
|
</List>
|
|
</Box>
|
|
) : null}
|
|
|
|
{hasCompanies ? <Divider>oder</Divider> : null}
|
|
|
|
<Box component="form" onSubmit={this.handleCreate} noValidate>
|
|
<Stack spacing={2.5}>
|
|
{hasCompanies ? (
|
|
<Typography variant="subtitle2">Neues Unternehmen anlegen</Typography>
|
|
) : null}
|
|
|
|
<TextInput
|
|
fullWidth
|
|
required
|
|
size="small"
|
|
label="Unternehmensname"
|
|
value={name}
|
|
disabled={busy}
|
|
onChange={(e) => this.setState({ name: e.target.value })}
|
|
autoFocus={!hasCompanies}
|
|
/>
|
|
|
|
<FormControl required>
|
|
<FormLabel sx={{ mb: 0.5 }}>Sprache</FormLabel>
|
|
<ToggleButtonGroup
|
|
exclusive
|
|
required
|
|
size="small"
|
|
value={iso639}
|
|
disabled={busy}
|
|
onChange={(_event, value) => {
|
|
if (value) this.setState({ iso639: value });
|
|
}}
|
|
>
|
|
<ToggleButton value="de">Deutsch</ToggleButton>
|
|
<ToggleButton value="en">English</ToggleButton>
|
|
</ToggleButtonGroup>
|
|
</FormControl>
|
|
|
|
<Stack direction="row" spacing={2}>
|
|
<TextInput
|
|
fullWidth
|
|
required
|
|
size="small"
|
|
type="number"
|
|
label="Normalsatz"
|
|
value={taxrate}
|
|
disabled={busy}
|
|
onChange={(e) => this.setState({ taxrate: e.target.value })}
|
|
error={taxrate !== '' && parsedTaxrate == null}
|
|
slotProps={{
|
|
input: {
|
|
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
|
},
|
|
htmlInput: { min: 0, max: 100, step: 0.01, required: true },
|
|
}}
|
|
/>
|
|
<TextInput
|
|
fullWidth
|
|
required
|
|
size="small"
|
|
type="number"
|
|
label="Ermäßigter Satz"
|
|
value={reducedtaxrate}
|
|
disabled={busy}
|
|
onChange={(e) => this.setState({ reducedtaxrate: e.target.value })}
|
|
error={reducedtaxrate !== '' && parsedReducedtaxrate == null}
|
|
slotProps={{
|
|
input: {
|
|
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
|
},
|
|
htmlInput: { min: 0, max: 100, step: 0.01, required: true },
|
|
}}
|
|
/>
|
|
</Stack>
|
|
|
|
{/* Nummernkreise Section (when no company was created yet or creating new) */}
|
|
<Box sx={{ pt: 1 }}>
|
|
<Stack direction="row" spacing={1} useFlexGap sx={{ flexWrap: 'wrap', mb: 1.5 }}>
|
|
{PRESETS.map((p) => {
|
|
const isSelected = selectedPreset === p.id;
|
|
return (
|
|
<Button
|
|
key={p.id}
|
|
size="small"
|
|
variant={isSelected ? 'contained' : 'outlined'}
|
|
color={isSelected ? 'primary' : 'inherit'}
|
|
disabled={busy}
|
|
onClick={() => this.applyPreset(p.id)}
|
|
startIcon={isSelected ? <AutoAwesomeIcon sx={{ fontSize: 16 }} /> : null}
|
|
sx={{ textTransform: 'none', borderRadius: 2 }}
|
|
>
|
|
{p.label}
|
|
</Button>
|
|
);
|
|
})}
|
|
{selectedPreset === 'custom' ? (
|
|
<Chip
|
|
size="small"
|
|
label="Benutzerdefiniert"
|
|
color="secondary"
|
|
variant="outlined"
|
|
sx={{ height: 30, borderRadius: 2 }}
|
|
/>
|
|
) : null}
|
|
</Stack>
|
|
|
|
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 1.5 }}>
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow sx={{ bgcolor: 'action.hover' }}>
|
|
<TableCell sx={{ fontWeight: 600 }}>Bereich</TableCell>
|
|
<TableCell sx={{ fontWeight: 600, width: 130 }}>Präfix</TableCell>
|
|
<TableCell sx={{ fontWeight: 600, width: 90 }}>Stellen</TableCell>
|
|
<TableCell sx={{ fontWeight: 600, width: 110 }}>Startnr.</TableCell>
|
|
<TableCell sx={{ fontWeight: 600 }}>Vorschau</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{NUMBER_SCHEMES.map(({ type, label, icon: Icon }) => {
|
|
const cfg = numbers[type] || { prefix: '', padding: 5, next: 1 };
|
|
return (
|
|
<TableRow key={type} hover>
|
|
<TableCell>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Icon sx={{ fontSize: 18, color: 'text.secondary', flexShrink: 0 }} />
|
|
<Typography variant="body2" sx={{ fontWeight: 500, whiteSpace: 'nowrap' }}>
|
|
{label}
|
|
</Typography>
|
|
</Box>
|
|
</TableCell>
|
|
<TableCell>
|
|
<TextInput
|
|
size="small"
|
|
value={cfg.prefix}
|
|
disabled={busy}
|
|
onChange={(e) => this.handleNumberChange(type, 'prefix', e.target.value)}
|
|
placeholder="z.B. ART-"
|
|
sx={{ input: { fontFamily: 'monospace', fontSize: '0.85rem' } }}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<TextInput
|
|
size="small"
|
|
type="number"
|
|
value={cfg.padding}
|
|
disabled={busy}
|
|
slotProps={{ htmlInput: { min: 0, max: 20, step: 1 } }}
|
|
onChange={(e) => this.handleNumberChange(type, 'padding', e.target.value)}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<TextInput
|
|
size="small"
|
|
type="number"
|
|
value={cfg.next}
|
|
disabled={busy}
|
|
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
|
onChange={(e) => this.handleNumberChange(type, 'next', e.target.value)}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
|
<Chip
|
|
size="small"
|
|
label={formatNumberPreview(cfg.prefix, cfg.padding, cfg.next, 0)}
|
|
sx={{
|
|
fontFamily: 'monospace',
|
|
fontWeight: 700,
|
|
bgcolor: 'primary.50',
|
|
color: 'primary.dark',
|
|
border: '1px solid',
|
|
borderColor: 'primary.200',
|
|
}}
|
|
/>
|
|
<Typography variant="caption" color="text.disabled">
|
|
→
|
|
</Typography>
|
|
<Chip
|
|
size="small"
|
|
variant="outlined"
|
|
label={formatNumberPreview(cfg.prefix, cfg.padding, cfg.next, 1)}
|
|
sx={{
|
|
fontFamily: 'monospace',
|
|
fontSize: '0.75rem',
|
|
color: 'text.secondary',
|
|
}}
|
|
/>
|
|
</Box>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
</Box>
|
|
|
|
<Button
|
|
type="submit"
|
|
variant="contained"
|
|
size="large"
|
|
disabled={busy || !canCreate}
|
|
sx={{ mt: 1 }}
|
|
>
|
|
Unternehmen anlegen
|
|
</Button>
|
|
</Stack>
|
|
</Box>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="text"
|
|
color="inherit"
|
|
disabled={busy}
|
|
onClick={this.handleLogout}
|
|
sx={{ alignSelf: 'center' }}
|
|
>
|
|
Abmelden
|
|
</Button>
|
|
</Stack>
|
|
)}
|
|
</DialogContent>
|
|
</AppDialog>
|
|
);
|
|
}
|
|
}
|
|
|
|
export default CompanySetupDialog;
|
|
|